Repository navigation
Add sidebar terminal modes - #3077
lawrencecchen wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds sidebar-mounted terminal support: new cmux sidebar config and decoding, UI modes for left/right sidebars, SidebarTerminalPanelView and workspace lifecycle for sidebar terminals, focus/close logic updated to treat sidebar terminals separately, and four new localization strings. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User/UI
participant Config as CmuxConfigStore
participant View as SidebarTerminalPanelView
participant Workspace as Workspace
participant Terminal as TerminalPanel
User->>Config: App launch → load configs
Config->>Config: Merge global + local sidebarConfiguration
User->>View: Select sidebar mode (left/right)
View->>Config: Request terminal config for placement
Config-->>View: Return CmuxSidebarTerminalDefinition?
View->>Workspace: sidebarTerminalPanel(for:placement, config, cwd)
Workspace->>Workspace: Check cached SidebarTerminalCreationSignature
alt signature matches
Workspace-->>Terminal: Reuse existing sidebar terminal panel
else
Workspace->>Terminal: Create new sidebar terminal panel (inherit config, set placement tag)
end
Terminal-->>Workspace: Return TerminalPanel
Workspace-->>View: Provide panel to render
View->>Terminal: Render/attach in sidebar and manage focus/visibility
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR adds selectable terminal modes to both sidebars: the left sidebar gets a Workspaces / Terminal toggle (persisted in Confidence Score: 5/5Safe to merge — all findings are P2 style/cleanup suggestions with no correctness or data-integrity impact. The implementation is thorough: sidebar terminals are correctly excluded from all panel counts, close guards, serialization, and focus-tracking paths. Config merging, panel lifecycle (reuse via signature, cleanup), localization, and tests are all handled correctly. The three flagged items are a deprecated API call, a misleading fallback message, and a minor O(n) inefficiency — none block merge. Sources/ContentView.swift (deprecated onChange API, misleading fallback message) and Sources/Workspace.swift (bonsplitPanelCount efficiency). Important Files Changed
Sequence DiagramsequenceDiagram
participant SV as SidebarTerminalPanelView
participant W as Workspace
participant TP as TerminalPanel
participant TV as TerminalPanelView
SV->>SV: onAppear / onChange(workspace.id) / onChange(configuration)
SV->>SV: compute preferredWorkingDirectory
SV->>W: sidebarTerminalPanel(for:placement, configuration:, preferredWorkingDirectory:)
W->>W: build SidebarTerminalCreationSignature
alt existing panel & signature matches
W->>W: applySidebarTerminalConfiguration (update title)
W-->>SV: return existing TerminalPanel
else signature changed or no panel
W->>W: closeSidebarTerminalPanel (cleanup all metadata)
W->>TP: TerminalPanel(workspaceId, GHOSTTY_SURFACE_CONTEXT_SPLIT, workingDirectory, initialInput, CMUX_SIDEBAR_TERMINAL env)
W->>W: register in panels[], sidebarTerminalPanelIds[], sidebarTerminalCreationSignatures[]
W-->>SV: return new TerminalPanel
end
SV->>TV: TerminalPanelView(panel, paneId, isFocused, isVisibleInUI)
SV->>SV: onDisappear → setVisibleInUI(false) + setActive(false)
|
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
Sources/Workspace.swift (3)
8378-8388:⚠️ Potential issue | 🟠 MajorClear local terminal metadata before remote promotion.
Line 8387 promotes an existing local terminal to remote-backed, but any previously reported local TTY/listening ports remain in
surfaceTTYNamesandsurfaceListeningPorts. Clear them before tracking so stale local metadata is not shown or scanned as remote state.Suggested fix
} guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + surfaceTTYNames.removeValue(forKey: initialPanelId) + surfaceListeningPorts.removeValue(forKey: initialPanelId) + recomputeListeningPorts() trackRemoteTerminalSurface(initialPanelId) }Based on learnings, In Sources/Workspace.swift: When promoting an existing local terminal to remote-backed in Workspace.seedInitialRemoteTerminalSessionIfNeeded(), clear both surfaceTTYNames[panelId] and surfaceListeningPorts[panelId] before calling trackRemoteTerminalSurface(:) to avoid stale local TTY/port metadata in the workspace UI.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 8378 - 8388, In seedInitialRemoteTerminalSessionIfNeeded(configuration:), before calling trackRemoteTerminalSurface(initialPanelId) clear any stale local metadata by removing entries for the panelId from surfaceTTYNames and surfaceListeningPorts (e.g. surfaceTTYNames[initialPanelId] = nil; surfaceListeningPorts[initialPanelId] = nil) so that previously-reported local TTY names and listening ports are not shown or scanned after promoting the panel to remote; keep the existing guards that locate terminalIds and activeRemoteTerminalSurfaceIds and perform the clearing just prior to invoking trackRemoteTerminalSurface(initialPanelId).
8163-8166:⚠️ Potential issue | 🟠 MajorKeep sidebar terminal TTYs out of remote port scans and refresh ports on close.
syncRemotePortScanTTYs()still forwards everysurfaceTTYNamesentry, so a local sidebar terminal in a remote workspace can be treated as a remote-scanned panel. Also,closeSidebarTerminalPanel(_:)removessurfaceListeningPortswithout recomputinglisteningPorts, leaving stale ports visible until another update.Suggested fix
func syncRemotePortScanTTYs() { guard isRemoteWorkspace else { return } - remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) + let remoteTTYNames = surfaceTTYNames.filter { panelId, _ in + !isSidebarTerminalPanel(panelId) + } + remoteSessionController?.updateRemotePortScanTTYs(remoteTTYNames) }surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) + remoteDetectedSurfaceIds.remove(panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) sidebarTerminalPanelIds.removeValue(forKey: placement) sidebarTerminalCreationSignatures.removeValue(forKey: placement) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) + syncRemotePortScanTTYs() + recomputeListeningPorts() return true }Also applies to: 7356-7381
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 8163 - 8166, syncRemotePortScanTTYs currently forwards all surfaceTTYNames to remoteSessionController.updateRemotePortScanTTYs, which causes local sidebar terminals to be treated as remote-scanned TTYs; change syncRemotePortScanTTYs to filter out sidebar terminal names (e.g. exclude entries that originate from the sidebar) before calling remoteSessionController.updateRemotePortScanTTYs. Additionally, in closeSidebarTerminalPanel(_:) after removing entries from surfaceListeningPorts, recompute and refresh the aggregated listeningPorts (or call the existing update path that sets listeningPorts from surfaceListeningPorts) so stale ports are not left visible; ensure you reference and update surfaceListeningPorts and listeningPorts in that flow so the UI is refreshed when a sidebar terminal is closed.
7958-7975:⚠️ Potential issue | 🟡 MinorFilter sidebar terminals out of workspace metadata ordering.
Because sidebar terminals are stored in
panelsbut have no Bonsplit tab, Line 7968 adds them as fallback IDs. That lets sidebar-terminal cwd/branch/PR state leak into the workspace directory/branch/PR lists. Keep this order Bonsplit-only.Suggested fix
- let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + let fallbackPanelIds = panels.keys + .filter(isBonsplitPanel) + .sorted { $0.uuidString < $1.uuidString }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 7958 - 7975, The current sidebarOrderedPanelIds() uses panels.keys as fallbackPanelIds, which includes sidebar-only terminals and leaks their state; instead compute fallbackPanelIds from the Bonsplit-derived paneTabs so the ordering is Bonsplit-only. In sidebarOrderedPanelIds(), replace the fallbackPanelIds assignment with a value built from paneTabs' values (flattened, deduplicated, converted back to UUIDs and sorted by uuidString) so only panel IDs that appear in bonsplitController tabs are used when calling SidebarBranchOrdering.orderedPanelIds.
🧹 Nitpick comments (2)
Sources/ContentView.swift (2)
10594-10736: DRY the mode bar and empty-state placeholder across branches.The two branches of the mode switch duplicate:
- The traffic-light spacer +
LeftSidebarModeBarblock (Lines 10598-10606 vs 10709-10716), including padding and theleftSidebarMode = mode.rawValueclosure.- The “No workspace selected” text (Line 10729) is also the exact fallback already emitted inside
SidebarTerminalPanelView(Lines 10478-10482), so the outerif let workspace = tabManager.selectedWorkspacebranch and the innerelsebranch will never coexist — one of them is dead code depending on which path is reached first.Recommend extracting a small
leftSidebarModeHeaderview and either (a) removing the outerselectedWorkspaceguard and always renderingSidebarTerminalPanelView(handing it an optional workspace), or (b) removing the inner fallback fromSidebarTerminalPanelViewso the placeholder lives in exactly one place.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/ContentView.swift` around lines 10594 - 10736, The LeftSidebarModeBar + Spacer block is duplicated between the workspaces branch and the other branch (see LeftSidebarModeBar and the Spacer using trafficLightPadding and the closure that sets leftSidebarMode = mode.rawValue) and the "No workspace selected" placeholder is duplicated between the outer if-let tabManager.selectedWorkspace and the inner fallback inside SidebarTerminalPanelView; extract a small leftSidebarModeHeader view (containing Spacer and LeftSidebarModeBar with the same padding/closure) and use it in both branches, then choose one of the two suggested fixes for the placeholder: either remove the outer if-let and always render SidebarTerminalPanelView by changing its API to accept an optional workspace (refactor SidebarTerminalPanelView to handle nil), or remove the internal fallback text from SidebarTerminalPanelView so the placeholder lives only where the outer branch renders it (keep tabManager.selectedWorkspace guard); update references to SidebarTerminalPanelView, LeftSidebarModeBar, leftSidebarMode, and tabManager.selectedWorkspace accordingly.
10488-10494: Migrate to two-parameter or zero-parameteronChange(of:)closure.Both
.onChange(of: workspace.id)and.onChange(of: configuration)use the deprecated single-parameter form (macOS 14+). Since neither closure uses the value parameter, they can be simplified to zero-parameter closures for consistency with modern SwiftUI patterns:.onChange(of: workspace.id) { panel = nil ensurePanel() } .onChange(of: configuration) { ensurePanel() }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/ContentView.swift` around lines 10488 - 10494, The two .onChange calls using the deprecated single-parameter closure should be converted to zero-parameter closures since the value isn't used: replace the .onChange(of: workspace.id) { _ in ... } and .onChange(of: configuration) { _ in ... } forms with zero-parameter closures that drop the unused parameter and keep the bodies (for workspace.id set panel = nil then call ensurePanel(), and for configuration just call ensurePanel()); update the occurrences around the ensurePanel() calls in ContentView where .onChange(of: workspace.id) and .onChange(of: configuration) are declared.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/CmuxConfig.swift`:
- Around line 82-86: In merging(overrides: CmuxSidebarConfiguration) update the
leftTerminal/rightTerminal merge to deep-merge terminal fields instead of
replacing the whole terminal object: when overrides.leftTerminal/rightTerminal
is non-nil, produce a new CmuxTerminal whose individual properties (e.g.,
command, title, cwd) take overrides' values when present and fall back to the
existing leftTerminal/rightTerminal values otherwise; if
overrides.leftTerminal/rightTerminal is nil, keep the original terminal
unchanged. Reference the merging(overrides:) function and the
leftTerminal/rightTerminal properties and merge each terminal field explicitly
rather than using the full-object nil-coalescing currently in place.
In `@Sources/ContentView.swift`:
- Around line 10459-10476: The sidebar terminal currently uses isFocused:
isVisibleInUI which makes visibility equal focus; change this to track real
focus state by adding a local `@State` private var hasFocus = false and pass
isFocused: hasFocus to TerminalPanelView (instead of isVisibleInUI), set
hasFocus = true inside the existing onFocus closure (which already calls
panel.surface.setFocus and requestBackgroundSurfaceStartIfNeeded), and clear
hasFocus (set to false) in an onDisappear handler and whenever the workspace's
actual focusedPaneId changes away from paneId (or when you receive a
first-responder change) so the GhosttyTerminalView/portal only activates when
the sidebar truly has focus.
- Around line 10459-10513: The code currently assigns an arbitrary bonsplit
paneId in the view's if-let (using workspace.bonsplitController.focusedPaneId ??
workspace.bonsplitController.allPaneIds.first) which leaks a Bonsplit-managed id
into the sidebar terminal's portal-hosting lifecycle; instead, use the
sidebar-specific stable id already provided by Workspace
(sidebarTerminalPanelIds[placement] or a small accessor like
sidebarTerminalPanelId(for: placement)) and pass that into TerminalPanelView as
paneId so the sidebar terminal uses a dedicated, stable paneId that won't be
tracked/modified by Bonsplit host assignment; update the conditional and any
references to paneId in this view (and ensurePanel logic if it derives pane ids)
to use the sidebar-specific id rather than bonsplitController values.
- Around line 10485-10502: In SidebarTerminalPanelView update lifecycle
handling: in the .onAppear block after ensurePanel() re-assert the hosted view's
UI state by calling panel?.hostedView.setVisibleInUI(true) and
panel?.hostedView.setActive(true); in the .onChange(of: workspace.id) handler
before setting panel = nil deactivate the previous panel by calling
panel?.hostedView.setVisibleInUI(false) and panel?.hostedView.setActive(false);
leave the .onChange(of: configuration) logic but double-check whether
applySidebarTerminalConfiguration should be invoked for mutable fields
(cwd/command) in sidebarTerminalPanel()/applySidebarTerminalConfiguration() if
configuration changes must be applied mid-session.
In `@Sources/GhosttyTerminalView.swift`:
- Around line 10791-10804: The current early-return for sidebar terminals (using
isSidebarTerminalPanel(surfaceId)) must be gated by workspace.focusedPanelId ==
terminalSurface.id so we only short-circuit when this sidebar panel is the
workspace’s focused panel; update the checks around
isSidebarTerminalPanel(surfaceId) and matchesCurrentTerminalFocusTarget to also
verify workspace.focusedPanelId equals the terminal surface id (use
workspace.focusedPanelId and terminalSurface.id) before skipping focus logic,
and apply the same change to the other similar block (around lines 10862-10864)
so scheduleAutomaticFirstResponderApply(reason:) / applyFirstResponderIfNeeded()
won’t steal focus from the other visible sidebar terminal.
In `@Sources/Workspace.swift`:
- Around line 7328-7333: The reconciliation loop that hides panels uses
renderedVisiblePanelIdsForCurrentLayout() and thus can mark sidebar terminals
(stored via panels[terminalPanel.id] and sidebarTerminalPanelIds) as hidden
because they never appear in the Bonsplit layout; update the reconciliation
logic (the code that iterates visible IDs during Bonsplit portal reconciliation)
to treat sidebar terminals as always-visible by checking sidebarTerminalPanelIds
and sidebarTerminalCreationSignatures (or by skipping panels whose id exists in
sidebarTerminalPanelIds) before setting them hidden, so sidebarTerminal panels
created in functions like seedTerminalInheritanceFontPoints and returned from
that creation path are never toggled off during layout follow-ups.
- Around line 7345-7354: applySidebarTerminalConfiguration currently only sets
non-empty configured titles, leaving stale titles when configuration title is
removed; update the function to handle the nil/empty case by explicitly
resetting the panel title and cache: when configuration?.title is nil or empty,
call panel.updateTitle with the panel's default/display title (or clear it) and
remove any entry from panelTitles for panel.id so the old title is not retained;
keep the existing behavior for non-empty titles that sets
panel.updateTitle(title) and panelTitles[panel.id] = panel.displayTitle.
---
Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 8378-8388: In
seedInitialRemoteTerminalSessionIfNeeded(configuration:), before calling
trackRemoteTerminalSurface(initialPanelId) clear any stale local metadata by
removing entries for the panelId from surfaceTTYNames and surfaceListeningPorts
(e.g. surfaceTTYNames[initialPanelId] = nil;
surfaceListeningPorts[initialPanelId] = nil) so that previously-reported local
TTY names and listening ports are not shown or scanned after promoting the panel
to remote; keep the existing guards that locate terminalIds and
activeRemoteTerminalSurfaceIds and perform the clearing just prior to invoking
trackRemoteTerminalSurface(initialPanelId).
- Around line 8163-8166: syncRemotePortScanTTYs currently forwards all
surfaceTTYNames to remoteSessionController.updateRemotePortScanTTYs, which
causes local sidebar terminals to be treated as remote-scanned TTYs; change
syncRemotePortScanTTYs to filter out sidebar terminal names (e.g. exclude
entries that originate from the sidebar) before calling
remoteSessionController.updateRemotePortScanTTYs. Additionally, in
closeSidebarTerminalPanel(_:) after removing entries from surfaceListeningPorts,
recompute and refresh the aggregated listeningPorts (or call the existing update
path that sets listeningPorts from surfaceListeningPorts) so stale ports are not
left visible; ensure you reference and update surfaceListeningPorts and
listeningPorts in that flow so the UI is refreshed when a sidebar terminal is
closed.
- Around line 7958-7975: The current sidebarOrderedPanelIds() uses panels.keys
as fallbackPanelIds, which includes sidebar-only terminals and leaks their
state; instead compute fallbackPanelIds from the Bonsplit-derived paneTabs so
the ordering is Bonsplit-only. In sidebarOrderedPanelIds(), replace the
fallbackPanelIds assignment with a value built from paneTabs' values (flattened,
deduplicated, converted back to UUIDs and sorted by uuidString) so only panel
IDs that appear in bonsplitController tabs are used when calling
SidebarBranchOrdering.orderedPanelIds.
---
Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 10594-10736: The LeftSidebarModeBar + Spacer block is duplicated
between the workspaces branch and the other branch (see LeftSidebarModeBar and
the Spacer using trafficLightPadding and the closure that sets leftSidebarMode =
mode.rawValue) and the "No workspace selected" placeholder is duplicated between
the outer if-let tabManager.selectedWorkspace and the inner fallback inside
SidebarTerminalPanelView; extract a small leftSidebarModeHeader view (containing
Spacer and LeftSidebarModeBar with the same padding/closure) and use it in both
branches, then choose one of the two suggested fixes for the placeholder: either
remove the outer if-let and always render SidebarTerminalPanelView by changing
its API to accept an optional workspace (refactor SidebarTerminalPanelView to
handle nil), or remove the internal fallback text from SidebarTerminalPanelView
so the placeholder lives only where the outer branch renders it (keep
tabManager.selectedWorkspace guard); update references to
SidebarTerminalPanelView, LeftSidebarModeBar, leftSidebarMode, and
tabManager.selectedWorkspace accordingly.
- Around line 10488-10494: The two .onChange calls using the deprecated
single-parameter closure should be converted to zero-parameter closures since
the value isn't used: replace the .onChange(of: workspace.id) { _ in ... } and
.onChange(of: configuration) { _ in ... } forms with zero-parameter closures
that drop the unused parameter and keep the bodies (for workspace.id set panel =
nil then call ensurePanel(), and for configuration just call ensurePanel());
update the occurrences around the ensurePanel() calls in ContentView where
.onChange(of: workspace.id) and .onChange(of: configuration) are declared.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1e3b4376-6a71-4cc1-bba3-c3d3b7a45373
📒 Files selected for processing (10)
Resources/Localizable.xcstringsSources/AppDelegate.swiftSources/AppleScriptSupport.swiftSources/CmuxConfig.swiftSources/ContentView.swiftSources/GhosttyTerminalView.swiftSources/RightSidebarPanelView.swiftSources/TerminalController.swiftSources/Workspace.swiftcmuxTests/CmuxConfigTests.swift
| func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration { | ||
| CmuxSidebarConfiguration( | ||
| leftTerminal: overrides.leftTerminal ?? leftTerminal, | ||
| rightTerminal: overrides.rightTerminal ?? rightTerminal | ||
| ) |
There was a problem hiding this comment.
Deep-merge terminal definitions instead of replacing them.
With the current merge, a local config like terminal: { "cwd": "app" } replaces the whole global terminal and drops its command/title, so “global config merged under local config” only works at the side level.
Suggested partial-override merge
struct CmuxSidebarTerminalDefinition: Codable, Sendable, Equatable {
@@
private static func normalizedOptionalString(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
+
+ func merging(overrides: CmuxSidebarTerminalDefinition) -> CmuxSidebarTerminalDefinition {
+ CmuxSidebarTerminalDefinition(
+ title: overrides.title ?? title,
+ command: overrides.command ?? command,
+ cwd: overrides.cwd ?? cwd
+ )
+ }
}
@@
struct CmuxSidebarConfiguration: Sendable, Equatable {
@@
+ private static func mergeTerminal(
+ _ base: CmuxSidebarTerminalDefinition?,
+ overrides: CmuxSidebarTerminalDefinition?
+ ) -> CmuxSidebarTerminalDefinition? {
+ guard let overrides else { return base }
+ guard let base else { return overrides }
+ return base.merging(overrides: overrides)
+ }
+
func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
CmuxSidebarConfiguration(
- leftTerminal: overrides.leftTerminal ?? leftTerminal,
- rightTerminal: overrides.rightTerminal ?? rightTerminal
+ leftTerminal: Self.mergeTerminal(leftTerminal, overrides: overrides.leftTerminal),
+ rightTerminal: Self.mergeTerminal(rightTerminal, overrides: overrides.rightTerminal)
)
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/CmuxConfig.swift` around lines 82 - 86, In merging(overrides:
CmuxSidebarConfiguration) update the leftTerminal/rightTerminal merge to
deep-merge terminal fields instead of replacing the whole terminal object: when
overrides.leftTerminal/rightTerminal is non-nil, produce a new CmuxTerminal
whose individual properties (e.g., command, title, cwd) take overrides' values
when present and fall back to the existing leftTerminal/rightTerminal values
otherwise; if overrides.leftTerminal/rightTerminal is nil, keep the original
terminal unchanged. Reference the merging(overrides:) function and the
leftTerminal/rightTerminal properties and merge each terminal field explicitly
rather than using the full-object nil-coalescing currently in place.
| var body: some View { | ||
| Group { | ||
| if let panel, let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first { | ||
| TerminalPanelView( | ||
| panel: panel, | ||
| paneId: paneId, | ||
| isFocused: isVisibleInUI, | ||
| isVisibleInUI: isVisibleInUI, | ||
| portalPriority: 4, | ||
| isSplit: false, | ||
| appearance: PanelAppearance.fromConfig(config), | ||
| hasUnreadNotification: false, | ||
| onFocus: { | ||
| panel.surface.setFocus(true) | ||
| panel.surface.requestBackgroundSurfaceStartIfNeeded() | ||
| }, | ||
| onTriggerFlash: {} | ||
| ) |
There was a problem hiding this comment.
❓ Verification inconclusive
Script executed:
#!/bin/bash
# Inspect how TerminalPanelView consumes isFocused to confirm whether passing
# true unconditionally causes focus/first-responder side effects.
ast-grep --pattern $'struct TerminalPanelView: View {
$$$
}'
rg -nP --type=swift -C3 '\bisFocused\b' -g 'Sources/**'Repository: manaflow-ai/cmux
Repository: manaflow-ai/cmux
Exit code: 0
stdout:
Sources/WorkspaceContentView.swift-246- static func panelVisibleInUI(
Sources/WorkspaceContentView.swift-247- isWorkspaceVisible: Bool,
Sources/WorkspaceContentView.swift-248- isSelectedInPane: Bool,
Sources/WorkspaceContentView.swift:249: isFocused: Bool
Sources/WorkspaceContentView.swift-250- ) -> Bool {
Sources/WorkspaceContentView.swift-251- guard isWorkspaceVisible else { return false }
Sources/WorkspaceContentView.swift-252- // During pane/tab reparenting, Bonsplit can transiently report selected=false
Sources/WorkspaceContentView.swift-253- // for the currently focused panel. Keep focused content visible to avoid blank frames.
Sources/WorkspaceContentView.swift:254: return isSelectedInPane || isFocused
Sources/WorkspaceContentView.swift-255- }
Sources/WorkspaceContentView.swift-256-
Sources/WorkspaceContentView.swift-257- var body: some View {
--
Sources/WorkspaceContentView.swift-282- // Content for each tab in bonsplit
Sources/WorkspaceContentView.swift-283- let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
Sources/WorkspaceContentView.swift-284- if let panel = workspace.panel(for: tab.id) {
Sources/WorkspaceContentView.swift:285: let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
Sources/WorkspaceContentView.swift-286- let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
Sources/WorkspaceContentView.swift-287- let isVisibleInUI = Self.panelVisibleInUI(
Sources/WorkspaceContentView.swift-288- isWorkspaceVisible: isWorkspaceVisible,
Sources/WorkspaceContentView.swift-289- isSelectedInPane: isSelectedInPane,
Sources/WorkspaceContentView.swift:290: isFocused: isFocused
Sources/WorkspaceContentView.swift-291- )
Sources/WorkspaceContentView.swift-292- let showsNotificationRing = Workspace.shouldShowUnreadIndicator(
Sources/WorkspaceContentView.swift-293- hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
--
Sources/WorkspaceContentView.swift-299- PanelContentView(
Sources/WorkspaceContentView.swift-300- panel: panel,
Sources/WorkspaceContentView.swift-301- paneId: paneId,
Sources/WorkspaceContentView.swift:302: isFocused: isFocused,
Sources/WorkspaceContentView.swift-303- isSelectedInPane: isSelectedInPane,
Sources/WorkspaceContentView.swift-304- isVisibleInUI: isVisibleInUI,
Sources/WorkspaceContentView.swift-305- portalPriority: workspacePortalPriority,
--
Sources/Panels/TerminalPanelView.swift-9- `@AppStorage`(NotificationPaneRingSettings.enabledKey)
Sources/Panels/TerminalPanelView.swift-10- private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
Sources/Panels/TerminalPanelView.swift-11- let paneId: PaneID
Sources/Panels/TerminalPanelView.swift:12: let isFocused: Bool
Sources/Panels/TerminalPanelView.swift-13- let isVisibleInUI: Bool
Sources/Panels/TerminalPanelView.swift-14- let portalPriority: Int
Sources/Panels/TerminalPanelView.swift-15- let isSplit: Bool
--
Sources/Panels/TerminalPanelView.swift-24- GhosttyTerminalView(
Sources/Panels/TerminalPanelView.swift-25- terminalSurface: panel.surface,
Sources/Panels/TerminalPanelView.swift-26- paneId: paneId,
Sources/Panels/TerminalPanelView.swift:27: isActive: isFocused,
Sources/Panels/TerminalPanelView.swift-28- isVisibleInUI: isVisibleInUI,
Sources/Panels/TerminalPanelView.swift-29- portalZPriority: portalPriority,
Sources/Panels/TerminalPanelView.swift:30: showsInactiveOverlay: isSplit && !isFocused,
Sources/Panels/TerminalPanelView.swift-31- showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled,
Sources/Panels/TerminalPanelView.swift-32- inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
Sources/Panels/TerminalPanelView.swift-33- inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
--
Sources/TabManager.swift-4436- ReactGrabShortcutPanelSnapshot(
Sources/TabManager.swift-4437- id: panel.id,
Sources/TabManager.swift-4438- panelType: panel.panelType,
Sources/TabManager.swift:4439: isFocused: panel.id == workspace.focusedPanelId
Sources/TabManager.swift-4440- )
Sources/TabManager.swift-4441- }
Sources/TabManager.swift-4442- guard let route = resolveReactGrabShortcutRoute(panels: snapshots),
--
Sources/Panels/BrowserPanelView.swift-385- `@ObservedObject` var panel: BrowserPanel
Sources/Panels/BrowserPanelView.swift-386- `@ObservedObject` private var browserProfileStore = BrowserProfileStore.shared
Sources/Panels/BrowserPanelView.swift-387- let paneId: PaneID
Sources/Panels/BrowserPanelView.swift:388: let isFocused: Bool
Sources/Panels/BrowserPanelView.swift-389- let isVisibleInUI: Bool
Sources/Panels/BrowserPanelView.swift-390- let portalPriority: Int
Sources/Panels/BrowserPanelView.swift-391- let onRequestPanelFocus: () -> Void
--
Sources/Panels/BrowserPanelView.swift-621-#if DEBUG
Sources/Panels/BrowserPanelView.swift-622- dlog(
Sources/Panels/BrowserPanelView.swift-623- "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
Sources/Panels/BrowserPanelView.swift:624: "isFocused=\(isFocused ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-625- "addressFocused=\(addressBarFocused ? 1 : 0)"
Sources/Panels/BrowserPanelView.swift-626- )
Sources/Panels/BrowserPanelView.swift-627-#endif
--
Sources/Panels/BrowserPanelView.swift-631-#endif
Sources/Panels/BrowserPanelView.swift-632- setAddressBarFocused(false, reason: "webView.clickIntent")
Sources/Panels/BrowserPanelView.swift-633- }
Sources/Panels/BrowserPanelView.swift:634: if !isFocused {
Sources/Panels/BrowserPanelView.swift-635- onRequestPanelFocus()
Sources/Panels/BrowserPanelView.swift-636- }
Sources/Panels/BrowserPanelView.swift-637- }
--
Sources/Panels/BrowserPanelView.swift-737- // an attached-inspector X-close.
Sources/Panels/BrowserPanelView.swift-738- panel.scheduleDeveloperToolsVisibilityLossCheck()
Sources/Panels/BrowserPanelView.swift-739- }
Sources/Panels/BrowserPanelView.swift:740: .onChange(of: isFocused) { focused in
Sources/Panels/BrowserPanelView.swift-741-#if DEBUG
Sources/Panels/BrowserPanelView.swift-742- logBrowserFocusState(
Sources/Panels/BrowserPanelView.swift-743- event: "panelFocus.onChange",
--
Sources/Panels/BrowserPanelView.swift-778- NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id)
Sources/Panels/BrowserPanelView.swift-779- // Only request panel focus if this pane isn't currently focused. When already
Sources/Panels/BrowserPanelView.swift-780- // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit.
Sources/Panels/BrowserPanelView.swift:781: if !isFocused {
Sources/Panels/BrowserPanelView.swift-782-#if DEBUG
Sources/Panels/BrowserPanelView.swift-783- logBrowserFocusState(event: "addressBarFocus.requestPanelFocus")
Sources/Panels/BrowserPanelView.swift-784-#endif
--
Sources/Panels/BrowserPanelView.swift-1176- refreshInlineCompletion()
Sources/Panels/BrowserPanelView.swift-1177- }
Sources/Panels/BrowserPanelView.swift-1178- ),
Sources/Panels/BrowserPanelView.swift:1179: isFocused: $addressBarFocused,
Sources/Panels/BrowserPanelView.swift-1180- inlineCompletion: inlineCompletion,
Sources/Panels/BrowserPanelView.swift-1181- placeholder: String(localized: "browser.addressBar.placeholder", defaultValue: "Search or enter URL"),
Sources/Panels/BrowserPanelView.swift-1182- onTap: {
--
Sources/Panels/BrowserPanelView.swift-1257- paneId: paneId,
Sources/Panels/BrowserPanelView.swift-1258- shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting,
Sources/Panels/BrowserPanelView.swift-1259- useLocalInlineHosting: useLocalInlineDeveloperToolsHosting,
Sources/Panels/BrowserPanelView.swift:1260: shouldFocusWebView: isFocused && !addressBarFocused,
Sources/Panels/BrowserPanelView.swift:1261: isPanelFocused: isFocused,
Sources/Panels/BrowserPanelView.swift-1262- portalZPriority: portalPriority,
Sources/Panels/BrowserPanelView.swift-1263- paneDropZone: paneDropZone,
Sources/Panels/BrowserPanelView.swift-1264- searchOverlay: panel.searchState.map { searchState in
--
Sources/Panels/BrowserPanelView.swift-1359- isPanelFocusedOverride: Bool? = nil
Sources/Panels/BrowserPanelView.swift-1360- ) {
Sources/Panels/BrowserPanelView.swift-1361- guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
Sources/Panels/BrowserPanelView.swift:1362: let isPanelFocused = isPanelFocusedOverride ?? isFocused
Sources/Panels/BrowserPanelView.swift-1363- let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
Sources/Panels/BrowserPanelView.swift-1364- if cmuxWebView.allowsFirstResponderAcquisition != next {
Sources/Panels/BrowserPanelView.swift-1365-#if DEBUG
--
Sources/Panels/BrowserPanelView.swift-1448- let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0
Sources/Panels/BrowserPanelView.swift-1449- var line =
Sources/Panels/BrowserPanelView.swift-1450- "browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " +
Sources/Panels/BrowserPanelView.swift:1451: "panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-1452- "suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-1453- "suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-1454- "webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)"
--
Sources/Panels/BrowserPanelView.swift-1731- }
Sources/Panels/BrowserPanelView.swift-1732-
Sources/Panels/BrowserPanelView.swift-1733- private func autoFocusOmnibarIfBlank() {
Sources/Panels/BrowserPanelView.swift:1734: guard isFocused else {
Sources/Panels/BrowserPanelView.swift-1735-#if DEBUG
Sources/Panels/BrowserPanelView.swift-1736- logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused")
Sources/Panels/BrowserPanelView.swift-1737-#endif
--
Sources/Panels/BrowserPanelView.swift-1975- inlineCompletion = omnibarInlineCompletionForDisplay(
Sources/Panels/BrowserPanelView.swift-1976- typedText: omnibarState.buffer,
Sources/Panels/BrowserPanelView.swift-1977- suggestions: omnibarState.suggestions,
Sources/Panels/BrowserPanelView.swift:1978: isFocused: addressBarFocused,
Sources/Panels/BrowserPanelView.swift-1979- selectionRange: omnibarSelectionRange,
Sources/Panels/BrowserPanelView.swift-1980- hasMarkedText: omnibarHasMarkedText
Sources/Panels/BrowserPanelView.swift-1981- )
--
Sources/Panels/BrowserPanelView.swift-2778-func omnibarInlineCompletionForDisplay(
Sources/Panels/BrowserPanelView.swift-2779- typedText: String,
Sources/Panels/BrowserPanelView.swift-2780- suggestions: [OmnibarSuggestion],
Sources/Panels/BrowserPanelView.swift:2781: isFocused: Bool,
Sources/Panels/BrowserPanelView.swift-2782- selectionRange: NSRange,
Sources/Panels/BrowserPanelView.swift-2783- hasMarkedText: Bool
Sources/Panels/BrowserPanelView.swift-2784-) -> OmnibarInlineCompletion? {
Sources/Panels/BrowserPanelView.swift:2785: guard isFocused else { return nil }
Sources/Panels/BrowserPanelView.swift-2786- guard !hasMarkedText else { return nil }
Sources/Panels/BrowserPanelView.swift-2787-
Sources/Panels/BrowserPanelView.swift-2788- let query = typedText.trimmingCharacters(in: .whitespacesAndNewlines)
--
Sources/Panels/BrowserPanelView.swift-3001-// MARK: - Omnibar State Machine
Sources/Panels/BrowserPanelView.swift-3002-
Sources/Panels/BrowserPanelView.swift-3003-struct OmnibarState: Equatable {
Sources/Panels/BrowserPanelView.swift:3004: var isFocused: Bool = false
Sources/Panels/BrowserPanelView.swift-3005- var currentURLString: String = ""
Sources/Panels/BrowserPanelView.swift-3006- var buffer: String = ""
Sources/Panels/BrowserPanelView.swift-3007- var suggestions: [OmnibarSuggestion] = []
--
Sources/Panels/BrowserPanelView.swift-3034-
Sources/Panels/BrowserPanelView.swift-3035- switch event {
Sources/Panels/BrowserPanelView.swift-3036- case .focusGained(let url):
Sources/Panels/BrowserPanelView.swift:3037: state.isFocused = true
Sources/Panels/BrowserPanelView.swift-3038- state.currentURLString = url
Sources/Panels/BrowserPanelView.swift-3039- state.buffer = url
Sources/Panels/BrowserPanelView.swift-3040- state.isUserEditing = false
--
Sources/Panels/BrowserPanelView.swift-3044- effects.shouldSelectAll = true
Sources/Panels/BrowserPanelView.swift-3045-
Sources/Panels/BrowserPanelView.swift-3046- case .focusLostRevertBuffer(let url):
Sources/Panels/BrowserPanelView.swift:3047: state.isFocused = false
Sources/Panels/BrowserPanelView.swift-3048- state.currentURLString = url
Sources/Panels/BrowserPanelView.swift-3049- state.buffer = url
Sources/Panels/BrowserPanelView.swift-3050- state.isUserEditing = false
--
Sources/Panels/BrowserPanelView.swift-3053- state.selectedSuggestionID = nil
Sources/Panels/BrowserPanelView.swift-3054-
Sources/Panels/BrowserPanelView.swift-3055- case .focusLostPreserveBuffer(let url):
Sources/Panels/BrowserPanelView.swift:3056: state.isFocused = false
Sources/Panels/BrowserPanelView.swift-3057- state.currentURLString = url
Sources/Panels/BrowserPanelView.swift-3058- state.isUserEditing = false
Sources/Panels/BrowserPanelView.swift-3059- state.suggestions = []
--
Sources/Panels/BrowserPanelView.swift-3071-
Sources/Panels/BrowserPanelView.swift-3072- case .bufferChanged(let newValue):
Sources/Panels/BrowserPanelView.swift-3073- state.buffer = newValue
Sources/Panels/BrowserPanelView.swift:3074: if state.isFocused {
Sources/Panels/BrowserPanelView.swift-3075- state.isUserEditing = (newValue != state.currentURLString)
Sources/Panels/BrowserPanelView.swift-3076- state.selectedSuggestionIndex = 0
Sources/Panels/BrowserPanelView.swift-3077- state.selectedSuggestionID = nil
--
Sources/Panels/BrowserPanelView.swift-3122- state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id
Sources/Panels/BrowserPanelView.swift-3123-
Sources/Panels/BrowserPanelView.swift-3124- case .escape:
Sources/Panels/BrowserPanelView.swift:3125: guard state.isFocused else { break }
Sources/Panels/BrowserPanelView.swift-3126- // Chrome semantics:
Sources/Panels/BrowserPanelView.swift-3127- // - If user input is in progress OR the popup is open: revert to the page URL and select-all.
Sources/Panels/BrowserPanelView.swift-3128- // - Otherwise: exit omnibar focus.
--
Sources/Panels/BrowserPanelView.swift-3473-
Sources/Panels/BrowserPanelView.swift-3474-private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
Sources/Panels/BrowserPanelView.swift-3475- `@Binding` var text: String
Sources/Panels/BrowserPanelView.swift:3476: `@Binding` var isFocused: Bool
Sources/Panels/BrowserPanelView.swift-3477- let inlineCompletion: OmnibarInlineCompletion?
Sources/Panels/BrowserPanelView.swift-3478- let placeholder: String
Sources/Panels/BrowserPanelView.swift-3479- let onTap: () -> Void
--
Sources/Panels/BrowserPanelView.swift-3521- return pendingFocusRequest ? "focus" : "blur"
Sources/Panels/BrowserPanelView.swift-3522- }()
Sources/Panels/BrowserPanelView.swift-3523- var line =
Sources/Panels/BrowserPanelView.swift:3524: "browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-3525- "pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-3526- "win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)"
Sources/Panels/BrowserPanelView.swift-3527- if !detail.isEmpty {
--
Sources/Panels/BrowserPanelView.swift-3610- return false
Sources/Panels/BrowserPanelView.swift-3611- }
Sources/Panels/BrowserPanelView.swift-3612- return browserOmnibarShouldReacquireFocusAfterEndEditing(
Sources/Panels/BrowserPanelView.swift:3613: desiredOmnibarFocus: parent.isFocused,
Sources/Panels/BrowserPanelView.swift-3614- nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
Sources/Panels/BrowserPanelView.swift-3615- )
Sources/Panels/BrowserPanelView.swift-3616- }
--
Sources/Panels/BrowserPanelView.swift-3619-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3620- logFocusEvent("controlTextDidBeginEditing")
Sources/Panels/BrowserPanelView.swift-3621-#endif
Sources/Panels/BrowserPanelView.swift:3622: if !parent.isFocused {
Sources/Panels/BrowserPanelView.swift-3623- DispatchQueue.main.async {
Sources/Panels/BrowserPanelView.swift-3624-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3625- self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1")
Sources/Panels/BrowserPanelView.swift-3626-#endif
Sources/Panels/BrowserPanelView.swift:3627: self.parent.isFocused = true
Sources/Panels/BrowserPanelView.swift-3628- }
Sources/Panels/BrowserPanelView.swift-3629- }
Sources/Panels/BrowserPanelView.swift-3630- attachSelectionObserverIfNeeded()
--
Sources/Panels/BrowserPanelView.swift-3640- detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)"
Sources/Panels/BrowserPanelView.swift-3641- )
Sources/Panels/BrowserPanelView.swift-3642-#endif
Sources/Panels/BrowserPanelView.swift:3643: if parent.isFocused {
Sources/Panels/BrowserPanelView.swift-3644- if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
Sources/Panels/BrowserPanelView.swift-3645-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3646- logFocusEvent("controlTextDidEndEditing.reacquire.begin")
--
Sources/Panels/BrowserPanelView.swift-3653-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3654- self.logFocusEvent("controlTextDidEndEditing.reacquire.tick")
Sources/Panels/BrowserPanelView.swift-3655-#endif
Sources/Panels/BrowserPanelView.swift:3656: guard self.parent.isFocused else { return }
Sources/Panels/BrowserPanelView.swift-3657- guard let field = self.parentField, let window = field.window else { return }
Sources/Panels/BrowserPanelView.swift-3658- guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
Sources/Panels/BrowserPanelView.swift-3659-#if DEBUG
--
Sources/Panels/BrowserPanelView.swift-4001- firstResponder === nsView ||
Sources/Panels/BrowserPanelView.swift-4002- nsView.currentEditor() != nil ||
Sources/Panels/BrowserPanelView.swift-4003- ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/Panels/BrowserPanelView.swift:4004: if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
Sources/Panels/BrowserPanelView.swift-4005-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4006- context.coordinator.logFocusEvent(
Sources/Panels/BrowserPanelView.swift-4007- "updateNSView.requestFocus.begin",
Sources/Panels/BrowserPanelView.swift:4008: detail: "isFocused=1 isFirstResponder=0"
Sources/Panels/BrowserPanelView.swift-4009- )
Sources/Panels/BrowserPanelView.swift-4010-#endif
Sources/Panels/BrowserPanelView.swift-4011- // Defer to avoid triggering input method XPC during layout pass,
--
Sources/Panels/BrowserPanelView.swift-4015- coordinator?.pendingFocusRequest = nil
Sources/Panels/BrowserPanelView.swift-4016- guard let nsView, let window = nsView.window else { return }
Sources/Panels/BrowserPanelView.swift-4017-#if DEBUG
Sources/Panels/BrowserPanelView.swift:4018: if coordinator?.parent.isFocused != true {
Sources/Panels/BrowserPanelView.swift-4019- coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state")
Sources/Panels/BrowserPanelView.swift-4020- return
Sources/Panels/BrowserPanelView.swift-4021- }
Sources/Panels/BrowserPanelView.swift-4022-#endif
Sources/Panels/BrowserPanelView.swift:4023: guard coordinator?.parent.isFocused == true else { return }
Sources/Panels/BrowserPanelView.swift-4024-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4025- coordinator?.logFocusEvent("updateNSView.requestFocus.tick")
Sources/Panels/BrowserPanelView.swift-4026-#endif
--
Sources/Panels/BrowserPanelView.swift-4034-#endif
Sources/Panels/BrowserPanelView.swift-4035- window.makeFirstResponder(nsView)
Sources/Panels/BrowserPanelView.swift-4036- }
Sources/Panels/BrowserPanelView.swift:4037: } else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false {
Sources/Panels/BrowserPanelView.swift-4038-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4039- context.coordinator.logFocusEvent(
Sources/Panels/BrowserPanelView.swift-4040- "updateNSView.requestBlur.begin",
Sources/Panels/BrowserPanelView.swift:4041: detail: "isFocused=0 isFirstResponder=1"
Sources/Panels/BrowserPanelView.swift-4042- )
Sources/Panels/BrowserPanelView.swift-4043-#endif
Sources/Panels/BrowserPanelView.swift-4044- context.coordinator.pendingFocusRequest = false
--
Sources/Panels/BrowserPanelView.swift-4046- coordinator?.pendingFocusRequest = nil
Sources/Panels/BrowserPanelView.swift-4047- guard let nsView, let window = nsView.window else { return }
Sources/Panels/BrowserPanelView.swift-4048-#if DEBUG
Sources/Panels/BrowserPanelView.swift:4049: if coordinator?.parent.isFocused == true {
Sources/Panels/BrowserPanelView.swift-4050- coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state")
Sources/Panels/BrowserPanelView.swift-4051- return
Sources/Panels/BrowserPanelView.swift-4052- }
Sources/Panels/BrowserPanelView.swift-4053-#endif
Sources/Panels/BrowserPanelView.swift:4054: guard coordinator?.parent.isFocused == false else { return }
Sources/Panels/BrowserPanelView.swift-4055-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4056- coordinator?.logFocusEvent("updateNSView.requestBlur.tick")
Sources/Panels/BrowserPanelView.swift-4057-#endif
--
Sources/Find/BrowserSearchOverlay.swift-23- HStack(spacing: 4) {
Sources/Find/BrowserSearchOverlay.swift-24- BrowserSearchTextFieldRepresentable(
Sources/Find/BrowserSearchOverlay.swift-25- text: $searchState.needle,
Sources/Find/BrowserSearchOverlay.swift:26: isFocused: $isSearchFieldFocused,
Sources/Find/BrowserSearchOverlay.swift-27- panelId: panelId,
Sources/Find/BrowserSearchOverlay.swift-28- focusRequestGeneration: focusRequestGeneration,
Sources/Find/BrowserSearchOverlay.swift-29- canApplyFocusRequest: canApplyFocusRequest,
--
Sources/Find/BrowserSearchOverlay.swift-197-
Sources/Find/BrowserSearchOverlay.swift-198-private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable {
Sources/Find/BrowserSearchOverlay.swift-199- `@Binding` var text: String
Sources/Find/BrowserSearchOverlay.swift:200: `@Binding` var isFocused: Bool
Sources/Find/BrowserSearchOverlay.swift-201- let panelId: UUID
Sources/Find/BrowserSearchOverlay.swift-202- let focusRequestGeneration: UInt64
Sources/Find/BrowserSearchOverlay.swift-203- let canApplyFocusRequest: (UInt64) -> Bool
--
Sources/Find/BrowserSearchOverlay.swift-240-
Sources/Find/BrowserSearchOverlay.swift-241- func controlTextDidBeginEditing(_ obj: Notification) {
Sources/Find/BrowserSearchOverlay.swift-242- parent.onFieldDidFocus()
Sources/Find/BrowserSearchOverlay.swift:243: if !parent.isFocused {
Sources/Find/BrowserSearchOverlay.swift-244- DispatchQueue.main.async {
Sources/Find/BrowserSearchOverlay.swift:245: self.parent.isFocused = true
Sources/Find/BrowserSearchOverlay.swift-246- }
Sources/Find/BrowserSearchOverlay.swift-247- }
Sources/Find/BrowserSearchOverlay.swift-248- }
Sources/Find/BrowserSearchOverlay.swift-249-
Sources/Find/BrowserSearchOverlay.swift-250- func controlTextDidEndEditing(_ obj: Notification) {
Sources/Find/BrowserSearchOverlay.swift:251: if parent.isFocused {
Sources/Find/BrowserSearchOverlay.swift-252- DispatchQueue.main.async {
Sources/Find/BrowserSearchOverlay.swift:253: self.parent.isFocused = false
Sources/Find/BrowserSearchOverlay.swift-254- }
Sources/Find/BrowserSearchOverlay.swift-255- }
Sources/Find/BrowserSearchOverlay.swift-256- }
--
Sources/Find/BrowserSearchOverlay.swift-331- nsView.currentEditor() != nil ||
Sources/Find/BrowserSearchOverlay.swift-332- ((fr as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/Find/BrowserSearchOverlay.swift-333-
Sources/Find/BrowserSearchOverlay.swift:334: if isFocused,
Sources/Find/BrowserSearchOverlay.swift-335- canApplyFocusRequest(focusRequestGeneration),
Sources/Find/BrowserSearchOverlay.swift-336- !isFirstResponder,
Sources/Find/BrowserSearchOverlay.swift-337- context.coordinator.pendingFocusRequest != true {
--
Sources/Find/BrowserSearchOverlay.swift-339- DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/Find/BrowserSearchOverlay.swift-340- coordinator?.pendingFocusRequest = nil
Sources/Find/BrowserSearchOverlay.swift-341- guard let coordinator,
Sources/Find/BrowserSearchOverlay.swift:342: coordinator.parent.isFocused,
Sources/Find/BrowserSearchOverlay.swift-343- coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return }
Sources/Find/BrowserSearchOverlay.swift-344- guard let nsView, let window = nsView.window else { return }
Sources/Find/BrowserSearchOverlay.swift-345- let fr = window.firstResponder
--
Sources/Find/SurfaceSearchOverlay.swift-36- HStack(spacing: 4) {
Sources/Find/SurfaceSearchOverlay.swift-37- SearchTextFieldRepresentable(
Sources/Find/SurfaceSearchOverlay.swift-38- text: $searchState.needle,
Sources/Find/SurfaceSearchOverlay.swift:39: isFocused: $isSearchFieldFocused,
Sources/Find/SurfaceSearchOverlay.swift-40- surfaceId: surfaceId,
Sources/Find/SurfaceSearchOverlay.swift-41- canApplyFocusRequest: canApplyFocusRequest,
Sources/Find/SurfaceSearchOverlay.swift-42- onFieldDidFocus: onFieldDidFocus,
--
Sources/Find/SurfaceSearchOverlay.swift-227-/// after window switching.
Sources/Find/SurfaceSearchOverlay.swift-228-private struct SearchTextFieldRepresentable: NSViewRepresentable {
Sources/Find/SurfaceSearchOverlay.swift-229- `@Binding` var text: String
Sources/Find/SurfaceSearchOverlay.swift:230: `@Binding` var isFocused: Bool
Sources/Find/SurfaceSearchOverlay.swift-231- let surfaceId: UUID
Sources/Find/SurfaceSearchOverlay.swift-232- let canApplyFocusRequest: () -> Bool
Sources/Find/SurfaceSearchOverlay.swift-233- let onFieldDidFocus: () -> Void
--
Sources/Find/SurfaceSearchOverlay.swift-262- dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
Sources/Find/SurfaceSearchOverlay.swift-263- `#endif`
Sources/Find/SurfaceSearchOverlay.swift-264- parent.onFieldDidFocus()
Sources/Find/SurfaceSearchOverlay.swift:265: if !parent.isFocused {
Sources/Find/SurfaceSearchOverlay.swift-266- DispatchQueue.main.async {
Sources/Find/SurfaceSearchOverlay.swift:267: self.parent.isFocused = true
Sources/Find/SurfaceSearchOverlay.swift-268- }
Sources/Find/SurfaceSearchOverlay.swift-269- }
Sources/Find/SurfaceSearchOverlay.swift-270- }
--
Sources/Find/SurfaceSearchOverlay.swift-273- `#if` DEBUG
Sources/Find/SurfaceSearchOverlay.swift-274- dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
Sources/Find/SurfaceSearchOverlay.swift-275- `#endif`
Sources/Find/SurfaceSearchOverlay.swift:276: if parent.isFocused {
Sources/Find/SurfaceSearchOverlay.swift-277- DispatchQueue.main.async {
Sources/Find/SurfaceSearchOverlay.swift:278: self.parent.isFocused = false
Sources/Find/SurfaceSearchOverlay.swift-279- }
Sources/Find/SurfaceSearchOverlay.swift-280- }
Sources/Find/SurfaceSearchOverlay.swift-281- }
--
Sources/Find/SurfaceSearchOverlay.swift-374- nsView.currentEditor() != nil ||
Sources/Find/SurfaceSearchOverlay.swift-375- ((fr as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/Find/SurfaceSearchOverlay.swift-376-
Sources/Find/SurfaceSearchOverlay.swift:377: if isFocused,
Sources/Find/SurfaceSearchOverlay.swift-378- canApplyFocusRequest(),
Sources/Find/SurfaceSearchOverlay.swift-379- !isFirstResponder,
Sources/Find/SurfaceSearchOverlay.swift-380- context.coordinator.pendingFocusRequest != true {
--
Sources/Find/SurfaceSearchOverlay.swift-382- DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/Find/SurfaceSearchOverlay.swift-383- coordinator?.pendingFocusRequest = nil
Sources/Find/SurfaceSearchOverlay.swift-384- guard let coordinator,
Sources/Find/SurfaceSearchOverlay.swift:385: coordinator.parent.isFocused,
Sources/Find/SurfaceSearchOverlay.swift-386- coordinator.parent.canApplyFocusRequest() else { return }
Sources/Find/SurfaceSearchOverlay.swift-387- guard let nsView, let window = nsView.window else { return }
Sources/Find/SurfaceSearchOverlay.swift-388- let fr = window.firstResponder
--
Sources/Panels/ReactGrab.swift-31-struct ReactGrabShortcutPanelSnapshot: Equatable {
Sources/Panels/ReactGrab.swift-32- let id: UUID
Sources/Panels/ReactGrab.swift-33- let panelType: PanelType
Sources/Panels/ReactGrab.swift:34: let isFocused: Bool
Sources/Panels/ReactGrab.swift-35-}
Sources/Panels/ReactGrab.swift-36-
Sources/Panels/ReactGrab.swift-37-struct ReactGrabShortcutRoute: Equatable {
--
Sources/Panels/ReactGrab.swift-42-func resolveReactGrabShortcutRoute(
Sources/Panels/ReactGrab.swift-43- panels: [ReactGrabShortcutPanelSnapshot]
Sources/Panels/ReactGrab.swift-44-) -> ReactGrabShortcutRoute? {
Sources/Panels/ReactGrab.swift:45: guard let focusedPanel = panels.first(where: \.isFocused) else { return nil }
Sources/Panels/ReactGrab.swift-46-
Sources/Panels/ReactGrab.swift-47- if focusedPanel.panelType == .browser {
Sources/Panels/ReactGrab.swift-48- return ReactGrabShortcutRoute(
--
Sources/Panels/PanelContentView.swift-6-struct PanelContentView: View {
Sources/Panels/PanelContentView.swift-7- let panel: any Panel
Sources/Panels/PanelContentView.swift-8- let paneId: PaneID
Sources/Panels/PanelContentView.swift:9: let isFocused: Bool
Sources/Panels/PanelContentView.swift-10- let isSelectedInPane: Bool
Sources/Panels/PanelContentView.swift-11- let isVisibleInUI: Bool
Sources/Panels/PanelContentView.swift-12- let portalPriority: Int
--
Sources/Panels/PanelContentView.swift-24- TerminalPanelView(
Sources/Panels/PanelContentView.swift-25- panel: terminalPanel,
Sources/Panels/PanelContentView.swift-26- paneId: paneId,
Sources/Panels/PanelContentView.swift:27: isFocused: isFocused,
Sources/Panels/PanelContentView.swift-28- isVisibleInUI: isVisibleInUI,
Sources/Panels/PanelContentView.swift-29- portalPriority: portalPriority,
Sources/Panels/PanelContentView.swift-30- isSplit: isSplit,
--
Sources/Panels/PanelContentView.swift-39- BrowserPanelView(
Sources/Panels/PanelContentView.swift-40- panel: browserPanel,
Sources/Panels/PanelContentView.swift-41- paneId: paneId,
Sources/Panels/PanelContentView.swift:42: isFocused: isFocused,
Sources/Panels/PanelContentView.swift-43- isVisibleInUI: isVisibleInUI,
Sources/Panels/PanelContentView.swift-44- portalPriority: portalPriority,
Sources/Panels/PanelContentView.swift-45- onRequestPanelFocus: onRequestPanelFocus
--
Sources/Panels/PanelContentView.swift-49- if let markdownPanel = panel as? MarkdownPanel {
Sources/Panels/PanelContentView.swift-50- MarkdownPanelView(
Sources/Panels/PanelContentView.swift-51- panel: markdownPanel,
Sources/Panels/PanelContentView.swift:52: isFocused: isFocused,
Sources/Panels/PanelContentView.swift-53- isVisibleInUI: isVisibleInUI,
Sources/Panels/PanelContentView.swift-54- portalPriority: portalPriority,
Sources/Panels/PanelContentView.swift-55- onRequestPanelFocus: onRequestPanelFocus
--
Sources/Panels/MarkdownPanelView.swift-5-/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI.
Sources/Panels/MarkdownPanelView.swift-6-struct MarkdownPanelView: View {
Sources/Panels/MarkdownPanelView.swift-7- `@ObservedObject` var panel: MarkdownPanel
Sources/Panels/MarkdownPanelView.swift:8: let isFocused: Bool
Sources/Panels/MarkdownPanelView.swift-9- let isVisibleInUI: Bool
Sources/Panels/MarkdownPanelView.swift-10- let portalPriority: Int
Sources/Panels/MarkdownPanelView.swift-11- let onRequestPanelFocus: () -> Void
--
Sources/ContentView.swift-4413- CommandPaletteSearchFieldRepresentable(
Sources/ContentView.swift-4414- placeholder: commandPaletteSearchPlaceholder,
Sources/ContentView.swift-4415- text: $commandPaletteQuery,
Sources/ContentView.swift:4416: isFocused: Binding(
Sources/ContentView.swift-4417- get: { isCommandPaletteSearchFocused },
Sources/ContentView.swift-4418- set: { isCommandPaletteSearchFocused = $0 }
Sources/ContentView.swift-4419- ),
--
Sources/ContentView.swift-4613- accessibilityLabel: accessibilityLabel,
Sources/ContentView.swift-4614- accessibilityIdentifier: accessibilityIdentifier,
Sources/ContentView.swift-4615- text: text,
Sources/ContentView.swift:4616: isFocused: focus,
Sources/ContentView.swift-4617- measuredHeight: measuredHeight,
Sources/ContentView.swift-4618- maxHeight: maxHeight,
Sources/ContentView.swift-4619- onSubmit: onSubmit,
--
Sources/ContentView.swift-4806- private struct CommandPaletteSearchFieldRepresentable: NSViewRepresentable {
Sources/ContentView.swift-4807- let placeholder: String
Sources/ContentView.swift-4808- `@Binding` var text: String
Sources/ContentView.swift:4809: `@Binding` var isFocused: Bool
Sources/ContentView.swift-4810- let onSubmit: () -> Void
Sources/ContentView.swift-4811- let onEscape: () -> Void
Sources/ContentView.swift-4812- let onMoveSelection: (Int) -> Void
--
Sources/ContentView.swift-4838- let editor = field.currentEditor() as? NSTextView {
Sources/ContentView.swift-4839- attachEditorTextDidChangeObserverIfNeeded(editor)
Sources/ContentView.swift-4840- }
Sources/ContentView.swift:4841: if !parent.isFocused {
Sources/ContentView.swift-4842- DispatchQueue.main.async {
Sources/ContentView.swift:4843: self.parent.isFocused = true
Sources/ContentView.swift-4844- }
Sources/ContentView.swift-4845- }
Sources/ContentView.swift-4846- }
--
Sources/ContentView.swift-4976- nsView.currentEditor() != nil ||
Sources/ContentView.swift-4977- ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/ContentView.swift-4978-
Sources/ContentView.swift:4979: if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
Sources/ContentView.swift-4980- context.coordinator.pendingFocusRequest = true
Sources/ContentView.swift-4981- DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/ContentView.swift-4982- coordinator?.pendingFocusRequest = nil
Sources/ContentView.swift:4983: guard let coordinator, coordinator.parent.isFocused else { return }
Sources/ContentView.swift-4984- guard let nsView, let window = nsView.window else { return }
Sources/ContentView.swift-4985- let firstResponder = window.firstResponder
Sources/ContentView.swift-4986- let alreadyFocused =
--
Sources/ContentView.swift-5345- let accessibilityLabel: String
Sources/ContentView.swift-5346- let accessibilityIdentifier: String
Sources/ContentView.swift-5347- `@Binding` var text: String
Sources/ContentView.swift:5348: `@Binding` var isFocused: Bool
Sources/ContentView.swift-5349- `@Binding` var measuredHeight: CGFloat
Sources/ContentView.swift-5350- let maxHeight: CGFloat
Sources/ContentView.swift-5351- let onSubmit: (String) -> Void
--
Sources/ContentView.swift-5363- func textDidBeginEditing(_ notification: Notification) {
Sources/ContentView.swift-5364-#if DEBUG
Sources/ContentView.swift-5365- dlog(
Sources/ContentView.swift:5366: "palette.wsDescription.editor.beginEditing focus=\(parent.isFocused ? 1 : 0) " +
Sources/ContentView.swift-5367- "responder=\(debugCommandPaletteResponderSummary(notification.object as? NSResponder))"
Sources/ContentView.swift-5368- )
Sources/ContentView.swift-5369-#endif
Sources/ContentView.swift:5370: if !parent.isFocused {
Sources/ContentView.swift-5371- DispatchQueue.main.async {
Sources/ContentView.swift:5372: self.parent.isFocused = true
Sources/ContentView.swift-5373- }
Sources/ContentView.swift-5374- }
Sources/ContentView.swift-5375- }
--
Sources/ContentView.swift-5394- func handleDidBecomeFirstResponder() {
Sources/ContentView.swift-5395-#if DEBUG
Sources/ContentView.swift-5396- dlog(
Sources/ContentView.swift:5397: "palette.wsDescription.editor.didBecomeFirstResponder focus=\(parent.isFocused ? 1 : 0)"
Sources/ContentView.swift-5398- )
Sources/ContentView.swift-5399-#endif
Sources/ContentView.swift:5400: if !parent.isFocused {
Sources/ContentView.swift:5401: parent.isFocused = true
Sources/ContentView.swift-5402- }
Sources/ContentView.swift-5403- }
Sources/ContentView.swift-5404-
--
Sources/ContentView.swift-5489- view.refreshMetrics()
Sources/ContentView.swift-5490-#if DEBUG
Sources/ContentView.swift-5491- dlog(
Sources/ContentView.swift:5492: "palette.wsDescription.editor.make focus=\(isFocused ? 1 : 0) " +
Sources/ContentView.swift-5493- "textLen=\((text as NSString).length) " +
Sources/ContentView.swift-5494- "height=\(String(format: "%.1f", measuredHeight))"
Sources/ContentView.swift-5495- )
--
Sources/ContentView.swift-5517-
Sources/ContentView.swift-5518- guard let window = nsView.window else {
Sources/ContentView.swift-5519-#if DEBUG
Sources/ContentView.swift:5520: if isFocused {
Sources/ContentView.swift-5521- dlog(
Sources/ContentView.swift-5522- "palette.wsDescription.editor.update waitingForWindow focus=1 " +
Sources/ContentView.swift-5523- "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0)"
--
Sources/ContentView.swift-5528- }
Sources/ContentView.swift-5529- let isFirstResponder = window.firstResponder === nsView.textView
Sources/ContentView.swift-5530-#if DEBUG
Sources/ContentView.swift:5531: if isFocused || context.coordinator.pendingFocusRequest {
Sources/ContentView.swift-5532- dlog(
Sources/ContentView.swift:5533: "palette.wsDescription.editor.update focus=\(isFocused ? 1 : 0) " +
Sources/ContentView.swift-5534- "isFirstResponder=\(isFirstResponder ? 1 : 0) " +
Sources/ContentView.swift-5535- "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0) " +
Sources/ContentView.swift-5536- "window={\(debugCommandPaletteWindowSummary(window))} " +
--
Sources/ContentView.swift-5538- )
Sources/ContentView.swift-5539- }
Sources/ContentView.swift-5540-#endif
Sources/ContentView.swift:5541: if isFocused, !isFirstResponder, !context.coordinator.pendingFocusRequest {
Sources/ContentView.swift-5542- context.coordinator.pendingFocusRequest = true
Sources/ContentView.swift-5543-#if DEBUG
Sources/ContentView.swift-5544- dlog(
--
Sources/ContentView.swift-5549- DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/ContentView.swift-5550- guard let coordinator else { return }
Sources/ContentView.swift-5551- coordinator.pendingFocusRequest = false
Sources/ContentView.swift:5552: guard coordinator.parent.isFocused, let nsView else { return }
Sources/ContentView.swift-5553- nsView.focusIfNeeded()
Sources/ContentView.swift-5554- }
Sources/ContentView.swift-5555- }
--
Sources/ContentView.swift-10462- TerminalPanelView(
Sources/ContentView.swift-10463- panel: panel,
Sources/ContentView.swift-10464- paneId: paneId,
Sources/ContentView.swift:10465: isFocused: isVisibleInUI,
Sources/ContentView.swift-10466- isVisibleInUI: isVisibleInUI,
Sources/ContentView.swift-10467- portalPriority: 4,
Sources/ContentView.swift-10468- isSplit: false,
Script executed:
# Find and inspect GhosttyTerminalView to see how isActive is used
rg -n "isActive" Sources/GhosttyTerminalView.swift -A 2 | head -100Repository: manaflow-ai/cmux
Repository: manaflow-ai/cmux
Exit code: 0
stdout:
1833: ghostty_app_set_focus(app, NSApp.isActive)
1834- }
1835-
--
8371: appIsActive: NSApp.isActive,
8372- windowIsKey: window.isKeyWindow,
8373- alreadyFirstResponder: alreadyFirstResponder,
--
8961: private var isActive = true
8962- private var lastFocusRefreshAt: CFTimeInterval = 0
8963- private var lastRequestedPortalOcclusionVisible: Bool?
--
9419: guard self.isActive || self.surfaceView.desiredFocus || self.isSurfaceViewFirstResponder() else {
9420- return
9421- }
--
10506: isActive
10507- }
10508-
--
10515: let wasActive = isActive
10516: isActive = active
10517-#if DEBUG
10518- if wasActive != active {
--
10569: "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " +
10570- "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
10571- "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
--
10755: guard isActive else { return }
10756- guard let window else { return }
10757- guard surfaceView.isVisibleInUI else {
--
10959: isActive,
10960- let window,
10961- window.isKeyWindow,
--
10983: guard isActive else { return }
10984- guard surfaceView.isVisibleInUI else { return }
10985- guard !isHiddenForFocus, hasUsablePortalGeometry else {
--
11321: let isActive: Bool
11322- let desiredFocus: Bool
11323- let isFirstResponder: Bool
--
11339: let appIsActive = NSApp.isActive
11340- let fr = window?.firstResponder as? NSView
11341- let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false)
--
11355: isActive: isActive,
11356- desiredFocus: surfaceView.desiredFocus,
11357- isFirstResponder: isFirstResponder
--
12323: var isActive: Bool = true
12324- var isVisibleInUI: Bool = true
12325- var portalZPriority: Int = 0
--
12480: previousDesiredIsActive != isActive ||
12481- previousDesiredIsVisibleInUI != isVisibleInUI ||
12482- previousDesiredPortalZPriority != portalZPriority
12483: coordinator.desiredIsActive = isActive
12484- coordinator.desiredIsVisibleInUI = isVisibleInUI
12485- coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing
--
12495: "active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
12496- "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
12497- "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
--
12502: "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
12503- "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
12504- "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
--
12552: "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " +
12553- "inWindow=\(hostedView.window != nil ? 1 : 0)"
12554- )
--
12560: "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)"
12561- )
12562- }
--
12713: hostedView.setActive(isActive)
12714- } else {
12715- // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates.
--
12724: "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
12725- )
12726- }
Script executed:
# Search for first-responder or keyboard focus side effects triggered by isActive
rg -n "firstResponder|makeFirstResponder" Sources/GhosttyTerminalView.swift -B 2 -A 2Repository: manaflow-ai/cmux
Repository: manaflow-ai/cmux
Exit code: 0
stdout:
5642- guard hitTest(location) == self else { return event }
5643-
5644: Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))")
5645- return event
5646- }
--
6418- if suppressingReparentFocus {
6419-#if DEBUG
6420: dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
6421-#endif
6422- return result
--
6434-#if DEBUG
6435- dlog(
6436: "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
6437- "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)"
6438- )
--
6445- Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
6446-#if DEBUG
6447: dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
6448- if let terminalSurface {
6449- AppDelegate.shared?.recordJumpUnreadFocusIfExpected(
--
6569-#endif
6570- guard event.type == .keyDown else { return false }
6571: guard let fr = window?.firstResponder as? NSView,
6572- fr === self || fr.isDescendant(of: self) else { return false }
6573- guard let surface = ensureSurfaceReadyForInput() else { return false }
--
7552- // repairs workspace/pane active state before key routing runs.
7553- requestPointerFocusRecovery()
7554: window?.makeFirstResponder(self)
7555- if let terminalSurface {
7556- AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
--
8043- let mods = GHOSTTY_MODS_NONE
8044-
8045: window?.makeFirstResponder(self)
8046- ghostty_surface_mouse_pos(surface, start.x, bounds.height - start.y, mods)
8047- _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
--
8137- let mods = modsFromFlags(flags)
8138-
8139: window?.makeFirstResponder(self)
8140- ghostty_surface_mouse_pos(surface, clampedPoint.x, bounds.height - clampedPoint.y, mods)
8141- let pressHandled = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
--
8169-
8170- requestPointerFocusRecovery()
8171: window?.makeFirstResponder(self)
8172- let point = convert(event.locationInWindow, from: nil)
8173- ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
--
8191- }
8192- requestPointerFocusRecovery()
8193: window?.makeFirstResponder(self)
8194- guard let surface = surface else { return }
8195- let point = convert(event.locationInWindow, from: nil)
--
8213- }
8214-
8215: window?.makeFirstResponder(self)
8216- let point = convert(event.locationInWindow, from: nil)
8217- ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
--
8365- private func maybeRequestFirstResponderForMouseFocus() {
8366- guard let window else { return }
8367: let alreadyFirstResponder = window.firstResponder === self
8368- let shouldRequest = Self.shouldRequestFirstResponderForMouseFocus(
8369- focusFollowsMouseEnabled: GhosttyApp.shared.focusFollowsMouseEnabled(),
--
8377- )
8378- guard shouldRequest else { return }
8379: window.makeFirstResponder(self)
8380- }
8381-
--
8406- guard let surface = surface else { return }
8407- lastScrollEventTime = CACurrentMediaTime()
8408: Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))")
8409- var x = event.scrollingDeltaX
8410- var y = event.scrollingDeltaY
--
8842- // which causes pane-content drift instead of terminal scrollback movement.
8843- GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll")
8844: if window?.firstResponder !== surfaceView {
8845: window?.makeFirstResponder(surfaceView)
8846- }
8847- surfaceView.scrollWheel(with: event)
--
9770- let searchActive = self.surfaceView.terminalSurface?.searchState != nil
9771-#if DEBUG
9772: dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))")
9773-#endif
9774- self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey")
--
9783- // Losing key window does not always trigger first-responder resignation, so force
9784- // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync.
9785: if let fr = window.firstResponder as? NSView,
9786- fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) {
9787-#if DEBUG
9788- dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder")
9789-#endif
9790: window.makeFirstResponder(nil)
9791- } else {
9792-#if DEBUG
9793: dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)")
9794-#endif
9795- }
--
10101- }
10102-
10103: let firstResponder = window.firstResponder
10104: let alreadyFocused = mountedSearchFieldOwnsResponder(firstResponder, field: field)
10105- guard !alreadyFocused else { return }
10106-
10107- surfaceView.terminalSurface?.setFocus(false)
10108: let result = window.makeFirstResponder(field)
10109-#if DEBUG
10110- dlog(
10111- "find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10112- "result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " +
10113: "firstResponder=\(String(describing: window.firstResponder))"
10114- )
10115-#endif
--
10484- if !visible {
10485- // If we were focused, yield first responder.
10486: if let window, let fr = window.firstResponder as? NSView,
10487- fr === surfaceView || fr.isDescendant(of: surfaceView) {
10488: window.makeFirstResponder(nil)
10489- }
10490- } else {
--
10543-
10544- private func debugFirstResponderLabel() -> String {
10545: guard let window, let firstResponder = window.firstResponder else { return "nil" }
10546: if let view = firstResponder as? NSView {
10547- if view === surfaceView {
10548- return "surfaceView"
--
10553- return String(describing: type(of: view))
10554- }
10555: return String(describing: type(of: firstResponder))
10556- }
10557-
--
10570- "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
10571- "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
10572: "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)"
10573- }
10574-#endif
--
10589- guard let window = self.window else { return }
10590-#if DEBUG
10591: let before = String(describing: window.firstResponder)
10592-#endif
10593- if let previous, previous !== self {
10594- _ = previous.surfaceView.resignFirstResponder()
10595- }
10596: let result = window.makeFirstResponder(self.surfaceView)
10597-#if DEBUG
10598- dlog(
10599- "find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10600: "result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
10601- )
10602-#endif
--
10698- ) -> Bool {
10699- guard let window else { return false }
10700: window.makeFirstResponder(surfaceView)
10701-
10702- let timestamp = ProcessInfo.processInfo.systemUptime
--
10811- "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10812- "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
10813: "firstResponder=\(String(describing: window.firstResponder))"
10814- )
10815-#endif
--
10818- }
10819-
10820: if let fr = window.firstResponder as? NSView,
10821- fr === surfaceView || fr.isDescendant(of: surfaceView) {
10822- reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
--
10836- window.makeKeyAndOrderFront(nil)
10837- }
10838: let result = window.makeFirstResponder(surfaceView)
10839-#if DEBUG
10840- dlog(
10841- "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10842- "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
10843: "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
10844- )
10845-#endif
--
10910- dlog(
10911- "focus.reparent.resume.restoreFirstResponder surface=\(surfaceShort) " +
10912: "firstResponder=\(String(describing: window.firstResponder))"
10913- )
10914-#endif
10915: guard window.makeFirstResponder(surfaceView), isSurfaceViewFirstResponder() else { return }
10916- }
10917-#if DEBUG
10918: dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))")
10919-#endif
10920- reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus")
--
10925- /// first responder transiently while focus is being applied.
10926- func isSurfaceViewFirstResponder() -> Bool {
10927: guard let window, let fr = window.firstResponder as? NSView else { return false }
10928- return fr === surfaceView || fr.isDescendant(of: surfaceView)
10929- }
--
11012- return
11013- }
11014: if let fr = window.firstResponder as? NSView,
11015- fr === surfaceView || fr.isDescendant(of: surfaceView) {
11016- reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
--
11018- }
11019- // Don't steal focus from a search overlay on another surface in this window.
11020: if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) {
11021-#if DEBUG
11022- dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused")
--
11029- // focus instantly because applyFirstResponderIfNeeded runs on a deferred async tick and
11030- // sees the field editor as a "foreign" responder to clobber.
11031: if window.firstResponder is NSText {
11032-#if DEBUG
11033- dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=textEditorFocused")
--
11036- }
11037-#if DEBUG
11038: dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
11039-#endif
11040: window.makeFirstResponder(surfaceView)
11041- if isSurfaceViewFirstResponder() {
11042- reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
--
11050- switch searchFocusTarget {
11051- case .searchField:
11052: if let firstResponder = window.firstResponder,
11053: isCurrentSurfaceSearchFieldResponder(firstResponder) {
11054- surfaceView.terminalSurface?.setFocus(false)
11055-#if DEBUG
11056- dlog(
11057- "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
11058: "reason=alreadyFocused firstResponder=\(String(describing: firstResponder))"
11059- )
11060-#endif
11061- return
11062- }
11063: if let firstResponder = window.firstResponder,
11064: isSearchOverlayOrDescendant(firstResponder),
11065: !isCurrentSurfaceSearchResponder(firstResponder) {
11066- surfaceView.terminalSurface?.setFocus(false)
11067-#if DEBUG
11068- dlog(
11069- "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
11070: "reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
11071- )
11072-#endif
--
11080- surfaceView.terminalSurface?.setFocus(false)
11081- // Post notification — SearchTextFieldRepresentable's Coordinator
11082: // observes it and calls makeFirstResponder on the native NSTextField.
11083- if let terminalSurface = surfaceView.terminalSurface {
11084- NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
--
11087- dlog(
11088- "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
11089: "via=notification firstResponder=\(String(describing: window.firstResponder))"
11090- )
11091-#endif
11092- case .terminal:
11093: let result = window.makeFirstResponder(surfaceView)
11094-#if DEBUG
11095- dlog(
11096- "find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
11097: "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
11098- )
11099-#endif
--
11113- }
11114-
11115: let firstResponder = window.firstResponder
11116: let alreadyFocused = mountedSearchFieldOwnsResponder(firstResponder, field: field)
11117-
11118- surfaceView.terminalSurface?.setFocus(false)
--
11122- dlog(
11123- "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
11124: "reason=mountedFieldAlreadyFocused firstResponder=\(String(describing: firstResponder))"
11125- )
11126- }
--
11128- guard !alreadyFocused else { return true }
11129-
11130: let result = window.makeFirstResponder(field)
11131: let ownsField = mountedSearchFieldOwnsResponder(window.firstResponder, field: field)
11132-
11133-#if DEBUG
11134- dlog(
11135- "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
11136: "via=mountedField result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
11137- )
11138-#endif
--
11143- func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
11144- if surfaceView.terminalSurface?.searchState != nil {
11145: if let firstResponder = window?.firstResponder as? NSView,
11146: (firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
11147- return .surface
11148- }
11149: if let firstResponder = window?.firstResponder,
11150: isCurrentSurfaceSearchResponder(firstResponder) {
11151- return .findField
11152- }
--
11217- dlog(
11218- "find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
11219: "target=searchField firstResponder=\(String(describing: window?.firstResponder))"
11220- )
11221-#endif
--
11238- `@discardableResult`
11239- func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
11240: guard let firstResponder = window.firstResponder,
11241: ownedPanelFocusIntent(for: firstResponder) == intent else {
11242- return false
11243- }
--
11256- private func resignOwnedFirstResponderIfNeeded(reason: String) {
11257- guard let window,
11258: let firstResponder = window.firstResponder else { return }
11259-
11260- let ownsSurfaceResponder: Bool = {
11261: guard let view = firstResponder as? NSView else { return false }
11262- return view === surfaceView || view.isDescendant(of: surfaceView)
11263- }()
11264-
11265: guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
11266-
11267-#if DEBUG
11268- dlog(
11269- "focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
11270: "reason=\(reason) firstResponder=\(String(describing: firstResponder))"
11271- )
11272-#endif
11273: window.makeFirstResponder(nil)
11274- }
11275-
11276- /// Check if a responder is inside a search overlay hosting view.
11277- /// Handles the AppKit field-editor case: when an NSTextField is being edited,
11278: /// window.firstResponder is the shared NSTextView field editor, not the text field.
11279- private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool {
11280- guard let view = resolvedKeyboardFocusOwnerView(for: responder) else { return false }
--
11338- let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false)
11339- let appIsActive = NSApp.isActive
11340: let fr = window?.firstResponder as? NSView
11341- let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false)
11342- return DebugRenderStats(
Script executed:
# Verify if sidebar terminal surface.setFocus() is the explicit focus intent or if isActive causes implicit focus
rg -n "\.setFocus" Sources/ContentView.swift -B 3 -A 1 | grep -A 4 "10470"Repository: manaflow-ai/cmux
Repository: manaflow-ai/cmux
Exit code: 0
stdout:
10470- hasUnreadNotification: false,
10471- onFocus: {
10472: panel.surface.setFocus(true)
10473- panel.surface.requestBackgroundSurfaceStartIfNeeded()
Sidebar terminal is always "focused" while visible — risk of focus stealing.
isFocused: isVisibleInUI combined with the call-site passing isVisibleInUI: true means the sidebar terminal reports itself focused any time the left-sidebar is in .terminal mode with a selected workspace. This propagates to GhosttyTerminalView, which calls hostedView.setActive(isActive) on the portal layer, enabling keyboard routing and first-responder acquisition for a surface that isn't actually focused. Depending on interaction patterns (window switching, divider dragging, workspace changes), this can:
- Allow the sidebar terminal to compete for keyboard first-responder with the main workspace's active panel.
- Disrupt focus routing when two terminals (sidebar + main) both signal
isActive=true.
isFocused should reflect whether the sidebar terminal actually holds focus (determined by user interaction or the onFocus callback), not merely visibility. Consider tracking a local @State private var hasFocus = false that is flipped by the onFocus closure and cleared on onDisappear or when another surface becomes first responder.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/ContentView.swift` around lines 10459 - 10476, The sidebar terminal
currently uses isFocused: isVisibleInUI which makes visibility equal focus;
change this to track real focus state by adding a local `@State` private var
hasFocus = false and pass isFocused: hasFocus to TerminalPanelView (instead of
isVisibleInUI), set hasFocus = true inside the existing onFocus closure (which
already calls panel.surface.setFocus and requestBackgroundSurfaceStartIfNeeded),
and clear hasFocus (set to false) in an onDisappear handler and whenever the
workspace's actual focusedPaneId changes away from paneId (or when you receive a
first-responder change) so the GhosttyTerminalView/portal only activates when
the sidebar truly has focus.
| var body: some View { | ||
| Group { | ||
| if let panel, let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first { | ||
| TerminalPanelView( | ||
| panel: panel, | ||
| paneId: paneId, | ||
| isFocused: isVisibleInUI, | ||
| isVisibleInUI: isVisibleInUI, | ||
| portalPriority: 4, | ||
| isSplit: false, | ||
| appearance: PanelAppearance.fromConfig(config), | ||
| hasUnreadNotification: false, | ||
| onFocus: { | ||
| panel.surface.setFocus(true) | ||
| panel.surface.requestBackgroundSurfaceStartIfNeeded() | ||
| }, | ||
| onTriggerFlash: {} | ||
| ) | ||
| } else { | ||
| Text(String(localized: "sidebar.terminal.noWorkspace", defaultValue: "No workspace selected")) | ||
| .font(.system(size: 12, weight: .medium)) | ||
| .foregroundStyle(.secondary) | ||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | ||
| } | ||
| } | ||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | ||
| .onAppear { | ||
| ensurePanel() | ||
| } | ||
| .onChange(of: workspace.id) { _ in | ||
| panel = nil | ||
| ensurePanel() | ||
| } | ||
| .onChange(of: configuration) { _ in | ||
| ensurePanel() | ||
| } | ||
| .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in | ||
| GhosttyConfig.invalidateLoadCache() | ||
| config = GhosttyConfig.load() | ||
| } | ||
| .onDisappear { | ||
| panel?.hostedView.setVisibleInUI(false) | ||
| panel?.hostedView.setActive(false) | ||
| } | ||
| } | ||
|
|
||
| private func ensurePanel() { | ||
| let nextPanel = workspace.sidebarTerminalPanel( | ||
| for: placement, | ||
| configuration: configuration, | ||
| preferredWorkingDirectory: preferredWorkingDirectory | ||
| ) | ||
| guard panel?.id != nextPanel.id else { return } | ||
| panel = nextPanel | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how paneId flows through TerminalPanelView and where it's used for
# routing (focus, close, bonsplit lookups).
rg -nP --type=swift -C4 '\bpaneId\b' -g 'Sources/ContentView.swift' | head -200
ast-grep --pattern $'struct TerminalPanelView: View {
$$$
let paneId: $_
$$$
}'
# Confirm sidebar-terminal panels are actually kept out of bonsplit allPaneIds.
rg -nP --type=swift -C3 '\b(sidebarTerminalPanelIds|isSidebarTerminalPanel)\b'Repository: manaflow-ai/cmux
Length of output: 12091
🏁 Script executed:
#!/bin/bash
# Find TerminalPanelView structure and how paneId is used
ast-grep --pattern $'struct TerminalPanelView: View {
$$$
}'
rg -nP --type=swift 'struct TerminalPanelView' -A 30 | head -100Repository: manaflow-ai/cmux
Length of output: 2775
🏁 Script executed:
#!/bin/bash
# Search for how paneId is used inside TerminalPanelView
rg -nP --type=swift 'paneId' Sources/Panels/TerminalPanelView.swift | head -50Repository: manaflow-ai/cmux
Length of output: 116
🏁 Script executed:
#!/bin/bash
# Check if paneId is passed to routing/focus operations
rg -nP --type=swift '\.setFocus\(|\.close\(|bonsplitController\.(focusPane|closePane|movePanel)' \
Sources/Panels/TerminalPanelView.swift | head -50Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Look for how sidebar terminal panels are actually handled - is there special handling?
rg -nP --type=swift -C5 'sidebarTerminalPanel\(' Sources/ContentView.swiftRepository: manaflow-ai/cmux
Length of output: 479
🏁 Script executed:
#!/bin/bash
# Check how GhosttyTerminalView uses the paneId parameter
rg -nP --type=swift 'paneId' Sources/GhosttyTerminalView.swift | head -80Repository: manaflow-ai/cmux
Length of output: 2078
🏁 Script executed:
#!/bin/bash
# Check for any routing calls that use paneId in the panel/terminal context
rg -nP --type=swift 'bonsplitController\.' Sources/Panels/TerminalPanelView.swift
rg -nP --type=swift 'bonsplitController\.' Sources/GhosttyTerminalView.swift | head -50Repository: manaflow-ai/cmux
Length of output: 819
🏁 Script executed:
#!/bin/bash
# Look for any methods that take paneId as routing parameter
rg -nP --type=swift 'func.*paneId.*\{' -A 5 Sources/Workspace.swift | head -100Repository: manaflow-ai/cmux
Length of output: 4857
🏁 Script executed:
#!/bin/bash
# Search for where paneId from TerminalPanelView/GhosttyTerminalView is used for routing
rg -nP --type=swift 'paneId.*forPanelId|panelIdFromSurfaceId' -B 2 -A 2 | head -100Repository: manaflow-ai/cmux
Length of output: 1883
🏁 Script executed:
#!/bin/bash
# Check if paneId from sidebar terminal ever gets used to query bonsplitController
rg -nP --type=swift 'bonsplitController\.tabs\(inPane|bonsplitController\.selectedTab' Sources/GhosttyTerminalView.swiftRepository: manaflow-ai/cmux
Length of output: 1883
🏁 Script executed:
#!/bin/bash
# Look at the portal host leasing logic - does it depend on paneId correctness?
rg -nP --type=swift 'activePortalHostLease' Sources/GhosttyTerminalView.swift -B 3 -A 3 | head -80Repository: manaflow-ai/cmux
Length of output: 1883
🏁 Script executed:
#!/bin/bash
# Check if sidebar terminal panels work correctly - any special bypass logic?
rg -nP --type=swift -C 5 'isSidebarTerminalPanel.*paneId|paneId.*isSidebarTerminalPanel'Repository: manaflow-ai/cmux
Length of output: 1883
Sidebar terminals should not use arbitrary Bonsplit paneIds.
Sidebar terminals are excluded from serialized layout and Bonsplit surface counts (confirmed by isSidebarTerminalPanel filtering in Workspace.swift), yet the code assigns an arbitrary bonsplit paneId from focusedPaneId ?? allPaneIds.first. While GhosttyTerminalView has defensive guards to skip bonsplit lookups for sidebar panels, the portal host leasing logic (GhosttyTerminalView lines 3738–4088) still tracks this paneId for host assignment. If the arbitrary pane is closed or modified, the sidebar terminal's portal state could be affected.
Sidebar terminals already have a dedicated stable identifier (sidebarTerminalPanelIds[placement]). Instead of borrowing a bonsplit paneId, create a dedicated sidebar paneId (e.g., a special marker or derived from placement) to properly isolate their lifecycle.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/ContentView.swift` around lines 10459 - 10513, The code currently
assigns an arbitrary bonsplit paneId in the view's if-let (using
workspace.bonsplitController.focusedPaneId ??
workspace.bonsplitController.allPaneIds.first) which leaks a Bonsplit-managed id
into the sidebar terminal's portal-hosting lifecycle; instead, use the
sidebar-specific stable id already provided by Workspace
(sidebarTerminalPanelIds[placement] or a small accessor like
sidebarTerminalPanelId(for: placement)) and pass that into TerminalPanelView as
paneId so the sidebar terminal uses a dedicated, stable paneId that won't be
tracked/modified by Bonsplit host assignment; update the conditional and any
references to paneId in this view (and ensurePanel logic if it derives pane ids)
to use the sidebar-specific id rather than bonsplitController values.
| .onAppear { | ||
| ensurePanel() | ||
| } | ||
| .onChange(of: workspace.id) { _ in | ||
| panel = nil | ||
| ensurePanel() | ||
| } | ||
| .onChange(of: configuration) { _ in | ||
| ensurePanel() | ||
| } | ||
| .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in | ||
| GhosttyConfig.invalidateLoadCache() | ||
| config = GhosttyConfig.load() | ||
| } | ||
| .onDisappear { | ||
| panel?.hostedView.setVisibleInUI(false) | ||
| panel?.hostedView.setActive(false) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
ast-grep --pattern $'func sidebarTerminalPanel(
for $_: $_,
configuration: $_,
preferredWorkingDirectory: $_
) -> $_ {
$$$
}'
rg -nP --type=swift -C3 '\bapplySidebarTerminalConfiguration\b'Repository: manaflow-ai/cmux
Length of output: 5695
🏁 Script executed:
rg -n 'func ensurePanel' Sources/ContentView.swift -A 20Repository: manaflow-ai/cmux
Length of output: 1002
🏁 Script executed:
rg -n 'struct SidebarTerminalPanelView' Sources/ContentView.swift -A 80Repository: manaflow-ai/cmux
Length of output: 3689
🏁 Script executed:
rg -n 'isVisibleInUI' Sources/Panels/TerminalPanelView.swift -B 2 -A 5Repository: manaflow-ai/cmux
Length of output: 777
🏁 Script executed:
rg -n 'private func applySidebarTerminalConfiguration' Sources/Workspace.swift -A 30Repository: manaflow-ai/cmux
Length of output: 1639
🏁 Script executed:
rg -n 'onChange.*isVisibleInUI\|\.onChange' Sources/Panels/TerminalPanelView.swift -B 2 -A 5 | head -80Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
rg -n 'setVisibleInUI\|setActive' Sources/Panels/TerminalPanelView.swift -B 2 -A 2Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
fd TerminalPanelView.swift Sources/Repository: manaflow-ai/cmux
Length of output: 98
🏁 Script executed:
rg -n 'setVisibleInUI\|setActive' Sources/ --type=swift -B 1 -A 1 | head -100Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
rg -n 'hostedView\.' Sources/Panels/TerminalPanelView.swift | head -20Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
rg -n 'setVisibleInUI|setActive' . --type=swift | grep -E '(TerminalPanelView|GhosttyTerminalView|SidebarTerminalPanelView)' | head -50Repository: manaflow-ai/cmux
Length of output: 1636
🏁 Script executed:
cat -n Sources/Panels/TerminalPanelView.swift | head -200Repository: manaflow-ai/cmux
Length of output: 2779
🏁 Script executed:
rg -n 'hostedView.setVisibleInUI\|hostedView.setActive' . --type=swiftRepository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
rg -n 'sidebarTerminalPanel\(' Sources/ContentView.swift -B 3 -A 3Repository: manaflow-ai/cmux
Length of output: 1883
🏁 Script executed:
rg -n 'func updateTitle' Sources/Workspace.swift -A 5Repository: manaflow-ai/cmux
Length of output: 1883
🏁 Script executed:
cat -n Sources/GhosttyTerminalView.swift | sed -n '12700,12730p'Repository: manaflow-ai/cmux
Length of output: 1883
onAppear lacks visibility/active re-assertion; onChange(workspace.id) doesn't deactivate old panel.
Several lifecycle issues in SidebarTerminalPanelView:
-
onAppearcallsensurePanel()but never re-asserts visibility/active on the hosted view. AfteronDisappearleaves the panel deactivated, toggling back to.terminalmode will leavesetVisibleInUI(false)/setActive(false)in effect until the next focus event. Addpanel?.hostedView.setVisibleInUI(true); panel?.hostedView.setActive(true)inonAppear. -
onChange(of: workspace.id)nilspanelwithout callingsetVisibleInUI(false)/setActive(false)on the previous panel, leaving the old workspace's sidebar-terminal hostedView "visible/active" from the UI's perspective. Deactivate the old panel before nil:panel?.hostedView.setVisibleInUI(false); panel?.hostedView.setActive(false). -
onChange(of: configuration)callsensurePanel(), which short-circuits if panel IDs match. The underlyingsidebarTerminalPanel()callsapplySidebarTerminalConfiguration()only on title; cwd/command are only applied at panel creation. This appears intentional (immutable after creation), but verify the expected behavior for mid-session config mutations.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/ContentView.swift` around lines 10485 - 10502, In
SidebarTerminalPanelView update lifecycle handling: in the .onAppear block after
ensurePanel() re-assert the hosted view's UI state by calling
panel?.hostedView.setVisibleInUI(true) and panel?.hostedView.setActive(true); in
the .onChange(of: workspace.id) handler before setting panel = nil deactivate
the previous panel by calling panel?.hostedView.setVisibleInUI(false) and
panel?.hostedView.setActive(false); leave the .onChange(of: configuration) logic
but double-check whether applySidebarTerminalConfiguration should be invoked for
mutable fields (cwd/command) in
sidebarTerminalPanel()/applySidebarTerminalConfiguration() if configuration
changes must be applied mid-session.
| if !tab.isSidebarTerminalPanel(surfaceId) { | ||
| guard let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), | ||
| let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in | ||
| tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) | ||
| }) else { | ||
| scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") | ||
| return | ||
| } | ||
|
|
||
| guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, | ||
| tab.bonsplitController.focusedPaneId == paneId else { | ||
| scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") | ||
| return | ||
| } |
There was a problem hiding this comment.
Gate sidebar focus by the workspace’s focused panel.
isSidebarTerminalPanel(surfaceId) only proves this panel is a sidebar terminal, not that it is the active focus target. With both left/right sidebar terminals available, matchesCurrentTerminalFocusTarget can return true for multiple visible sidebar terminals, so a deferred applyFirstResponderIfNeeded() may steal keyboard focus from the sidebar terminal the user actually selected.
Consider mirroring the existing focus ownership check used for search focus (workspace.focusedPanelId == terminalSurface.id) before short-circuiting sidebar panels.
🐛 Proposed focus ownership gate
- if !tab.isSidebarTerminalPanel(surfaceId) {
+ if tab.isSidebarTerminalPanel(surfaceId) {
+ guard tab.focusedPanelId == surfaceId else {
+ scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedSidebarPanel")
+ return
+ }
+ } else {
guard let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
@@
- if tab.isSidebarTerminalPanel(surfaceId) {
- return true
- }
+ if tab.isSidebarTerminalPanel(surfaceId) {
+ return tab.focusedPanelId == surfaceId
+ }Also applies to: 10862-10864
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/GhosttyTerminalView.swift` around lines 10791 - 10804, The current
early-return for sidebar terminals (using isSidebarTerminalPanel(surfaceId))
must be gated by workspace.focusedPanelId == terminalSurface.id so we only
short-circuit when this sidebar panel is the workspace’s focused panel; update
the checks around isSidebarTerminalPanel(surfaceId) and
matchesCurrentTerminalFocusTarget to also verify workspace.focusedPanelId equals
the terminal surface id (use workspace.focusedPanelId and terminalSurface.id)
before skipping focus logic, and apply the same change to the other similar
block (around lines 10862-10864) so
scheduleAutomaticFirstResponderApply(reason:) / applyFirstResponderIfNeeded()
won’t steal focus from the other visible sidebar terminal.
| panels[terminalPanel.id] = terminalPanel | ||
| panelTitles[terminalPanel.id] = terminalPanel.displayTitle | ||
| seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: inheritedConfig) | ||
| sidebarTerminalPanelIds[placement] = terminalPanel.id | ||
| sidebarTerminalCreationSignatures[placement] = signature | ||
| return terminalPanel |
There was a problem hiding this comment.
Prevent Bonsplit portal reconciliation from hiding sidebar terminals.
Line 7328 stores sidebar terminals in panels, but the Bonsplit portal visibility passes only consider panels present in the Bonsplit layout. Since sidebar terminals never appear in renderedVisiblePanelIdsForCurrentLayout(), these loops can mark an actively rendered sidebar terminal hidden during any layout follow-up.
Suggested fix
- for panel in panels.values {
- guard let terminalPanel = panel as? TerminalPanel else { continue }
+ for panel in panels.values {
+ guard let terminalPanel = panel as? TerminalPanel,
+ !isSidebarTerminalPanel(terminalPanel.id) else { continue }
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible {
terminalPanel.hostedView.setVisibleInUI(shouldBeVisible)
didChange = true
}- for panel in panels.values {
- guard let terminalPanel = panel as? TerminalPanel else { continue }
+ for panel in panels.values {
+ guard let terminalPanel = panel as? TerminalPanel,
+ !isSidebarTerminalPanel(terminalPanel.id) else { continue }
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
let hostedView = terminalPanel.hostedViewAlso applies to: 10950-10966, 10974-10986
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/Workspace.swift` around lines 7328 - 7333, The reconciliation loop
that hides panels uses renderedVisiblePanelIdsForCurrentLayout() and thus can
mark sidebar terminals (stored via panels[terminalPanel.id] and
sidebarTerminalPanelIds) as hidden because they never appear in the Bonsplit
layout; update the reconciliation logic (the code that iterates visible IDs
during Bonsplit portal reconciliation) to treat sidebar terminals as
always-visible by checking sidebarTerminalPanelIds and
sidebarTerminalCreationSignatures (or by skipping panels whose id exists in
sidebarTerminalPanelIds) before setting them hidden, so sidebarTerminal panels
created in functions like seedTerminalInheritanceFontPoints and returned from
that creation path are never toggled off during layout follow-ups.
| private func applySidebarTerminalConfiguration( | ||
| _ configuration: CmuxSidebarTerminalDefinition?, | ||
| to panel: TerminalPanel | ||
| ) { | ||
| guard let title = configuration?.title? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !title.isEmpty else { return } | ||
| panel.updateTitle(title) | ||
| panelTitles[panel.id] = panel.displayTitle | ||
| } |
There was a problem hiding this comment.
Clear stale sidebar titles when config title is removed.
This only applies non-empty configured titles. If cmux.json removes or empties title while cwd/command stay the same, the cached sidebar terminal keeps the old title. Add an explicit reset path for nil/empty titles.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/Workspace.swift` around lines 7345 - 7354,
applySidebarTerminalConfiguration currently only sets non-empty configured
titles, leaving stale titles when configuration title is removed; update the
function to handle the nil/empty case by explicitly resetting the panel title
and cache: when configuration?.title is nil or empty, call panel.updateTitle
with the panel's default/display title (or clear it) and remove any entry from
panelTitles for panel.id so the old title is not retained; keep the existing
behavior for non-empty titles that sets panel.updateTitle(title) and
panelTitles[panel.id] = panel.displayTitle.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1eee5e93fc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if workspace.bonsplitPanelCount == 1 { | ||
| if state.tabManager.tabs.count > 1 { | ||
| state.tabManager.closeWorkspace(workspace) |
There was a problem hiding this comment.
Close only the targeted terminal in AppleScript handler
This branch now uses bonsplitPanelCount for every terminal object, so closing a sidebar terminal via AppleScript can close the whole workspace/window when there is only one Bonsplit surface. Sidebar terminals are script-addressable through scriptingTerminalPanels()/terminalPanel(for:), so a script that asks to close a specific sidebar terminal can unexpectedly terminate unrelated primary surfaces.
Useful? React with 👍 / 👎.
| ) | ||
| configureTerminalPanel(terminalPanel) | ||
| applySidebarTerminalConfiguration(configuration, to: terminalPanel) | ||
| panels[terminalPanel.id] = terminalPanel |
There was a problem hiding this comment.
Exclude sidebar terminals from panel-count close semantics
Adding sidebar terminals to panels here makes legacy TabManager checks that still use workspace.panels.count misclassify workspaces as multi-surface. In particular, last-surface flows (like Cmd+W "close workspace on last surface" and child-exit collapse paths) can stop closing the workspace once a sidebar terminal exists, even though only one Bonsplit surface remains.
Useful? React with 👍 / 👎.
| guard let title = configuration?.title? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !title.isEmpty else { return } | ||
| panel.updateTitle(title) |
There was a problem hiding this comment.
Reset sidebar title when config title is removed
This only applies a new title when configuration.title is non-empty, but does nothing when the title is removed from config. Because panel reuse is keyed by command/cwd signature, reloading config after deleting sidebar.<side>.terminal.title leaves the old custom title stuck until the panel is recreated.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
5 issues found across 10 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/ContentView.swift">
<violation number="1" location="Sources/ContentView.swift:10465">
P1: Passing `isFocused: isVisibleInUI` means the sidebar terminal always reports itself as focused whenever visible. This propagates as `isActive: true` to `GhosttyTerminalView`, which enables keyboard routing and first-responder acquisition on the underlying surface. When both a sidebar terminal and the main workspace terminal are visible, both signal active, causing focus contention. Track actual focus state (e.g., via a `@State` toggled by the `onFocus` closure and cleared on disappear) instead of tying it to visibility.</violation>
</file>
<file name="Sources/Workspace.swift">
<violation number="1" location="Sources/Workspace.swift:7349">
P2: `applySidebarTerminalConfiguration` returns early when the configured title is nil or empty, so removing `title` from `cmux.json` while keeping other fields unchanged leaves the cached panel with its old title indefinitely. Reset the panel title when no configured title is present.</violation>
<violation number="2" location="Sources/Workspace.swift:7373">
P2: Missing `syncRemotePortScanTTYs()` after removing `surfaceTTYNames` entry. The `didCloseTab` path always calls this to reconcile remote port scan TTYs after removing a TTY name; the sidebar close path should do the same to avoid stale remote port scan references.</violation>
</file>
<file name="Sources/CmuxConfig.swift">
<violation number="1" location="Sources/CmuxConfig.swift:84">
P1: Shallow merge of sidebar terminal config replaces the entire `CmuxSidebarTerminalDefinition` when a local override specifies any field. A local config like `{ "left": { "terminal": { "cwd": "app" } } }` will nil-out the global `command` and `title` because `overrides.leftTerminal` is non-nil and replaces `leftTerminal` wholesale. Deep-merge individual fields (`title`, `command`, `cwd`) to match the documented "global config merged under local config" behavior.</violation>
</file>
<file name="Sources/GhosttyTerminalView.swift">
<violation number="1" location="Sources/GhosttyTerminalView.swift:10862">
P2: `matchesCurrentTerminalFocusTarget` unconditionally returns `true` for any sidebar terminal panel. With both left and right sidebar terminals visible, deferred `applyFirstResponderIfNeeded()` calls will treat both as valid focus targets, potentially stealing keyboard focus from the sidebar terminal the user actually interacted with. Gate this on `tab.focusedPanelId == surfaceId` to match the existing focus-ownership pattern.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| TerminalPanelView( | ||
| panel: panel, | ||
| paneId: paneId, | ||
| isFocused: isVisibleInUI, |
There was a problem hiding this comment.
P1: Passing isFocused: isVisibleInUI means the sidebar terminal always reports itself as focused whenever visible. This propagates as isActive: true to GhosttyTerminalView, which enables keyboard routing and first-responder acquisition on the underlying surface. When both a sidebar terminal and the main workspace terminal are visible, both signal active, causing focus contention. Track actual focus state (e.g., via a @State toggled by the onFocus closure and cleared on disappear) instead of tying it to visibility.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ContentView.swift, line 10465:
<comment>Passing `isFocused: isVisibleInUI` means the sidebar terminal always reports itself as focused whenever visible. This propagates as `isActive: true` to `GhosttyTerminalView`, which enables keyboard routing and first-responder acquisition on the underlying surface. When both a sidebar terminal and the main workspace terminal are visible, both signal active, causing focus contention. Track actual focus state (e.g., via a `@State` toggled by the `onFocus` closure and cleared on disappear) instead of tying it to visibility.</comment>
<file context>
@@ -10366,12 +10366,171 @@ private struct SidebarTabItemPresentationSnapshot: Equatable {
+ TerminalPanelView(
+ panel: panel,
+ paneId: paneId,
+ isFocused: isVisibleInUI,
+ isVisibleInUI: isVisibleInUI,
+ portalPriority: 4,
</file context>
|
|
||
| func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration { | ||
| CmuxSidebarConfiguration( | ||
| leftTerminal: overrides.leftTerminal ?? leftTerminal, |
There was a problem hiding this comment.
P1: Shallow merge of sidebar terminal config replaces the entire CmuxSidebarTerminalDefinition when a local override specifies any field. A local config like { "left": { "terminal": { "cwd": "app" } } } will nil-out the global command and title because overrides.leftTerminal is non-nil and replaces leftTerminal wholesale. Deep-merge individual fields (title, command, cwd) to match the documented "global config merged under local config" behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/CmuxConfig.swift, line 84:
<comment>Shallow merge of sidebar terminal config replaces the entire `CmuxSidebarTerminalDefinition` when a local override specifies any field. A local config like `{ "left": { "terminal": { "cwd": "app" } } }` will nil-out the global `command` and `title` because `overrides.leftTerminal` is non-nil and replaces `leftTerminal` wholesale. Deep-merge individual fields (`title`, `command`, `cwd`) to match the documented "global config merged under local config" behavior.</comment>
<file context>
@@ -4,6 +4,87 @@ import Foundation
+
+ func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
+ CmuxSidebarConfiguration(
+ leftTerminal: overrides.leftTerminal ?? leftTerminal,
+ rightTerminal: overrides.rightTerminal ?? rightTerminal
+ )
</file context>
| manualUnreadMarkedAt.removeValue(forKey: panelId) | ||
| panelSubscriptions.removeValue(forKey: panelId) | ||
| panelShellActivityStates.removeValue(forKey: panelId) | ||
| surfaceTTYNames.removeValue(forKey: panelId) |
There was a problem hiding this comment.
P2: Missing syncRemotePortScanTTYs() after removing surfaceTTYNames entry. The didCloseTab path always calls this to reconcile remote port scan TTYs after removing a TTY name; the sidebar close path should do the same to avoid stale remote port scan references.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 7373:
<comment>Missing `syncRemotePortScanTTYs()` after removing `surfaceTTYNames` entry. The `didCloseTab` path always calls this to reconcile remote port scan TTYs after removing a TTY name; the sidebar close path should do the same to avoid stale remote port scan references.</comment>
<file context>
@@ -7252,6 +7261,126 @@ final class Workspace: Identifiable, ObservableObject {
+ manualUnreadMarkedAt.removeValue(forKey: panelId)
+ panelSubscriptions.removeValue(forKey: panelId)
+ panelShellActivityStates.removeValue(forKey: panelId)
+ surfaceTTYNames.removeValue(forKey: panelId)
+ surfaceListeningPorts.removeValue(forKey: panelId)
+ restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
</file context>
| surfaceTTYNames.removeValue(forKey: panelId) | |
| surfaceTTYNames.removeValue(forKey: panelId) | |
| syncRemotePortScanTTYs() |
| guard let title = configuration?.title? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !title.isEmpty else { return } | ||
| panel.updateTitle(title) | ||
| panelTitles[panel.id] = panel.displayTitle |
There was a problem hiding this comment.
P2: applySidebarTerminalConfiguration returns early when the configured title is nil or empty, so removing title from cmux.json while keeping other fields unchanged leaves the cached panel with its old title indefinitely. Reset the panel title when no configured title is present.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 7349:
<comment>`applySidebarTerminalConfiguration` returns early when the configured title is nil or empty, so removing `title` from `cmux.json` while keeping other fields unchanged leaves the cached panel with its old title indefinitely. Reset the panel title when no configured title is present.</comment>
<file context>
@@ -7252,6 +7261,126 @@ final class Workspace: Identifiable, ObservableObject {
+ _ configuration: CmuxSidebarTerminalDefinition?,
+ to panel: TerminalPanel
+ ) {
+ guard let title = configuration?.title?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !title.isEmpty else { return }
</file context>
| guard let title = configuration?.title? | |
| .trimmingCharacters(in: .whitespacesAndNewlines), | |
| !title.isEmpty else { return } | |
| panel.updateTitle(title) | |
| panelTitles[panel.id] = panel.displayTitle | |
| let title = configuration?.title? | |
| .trimmingCharacters(in: .whitespacesAndNewlines) | |
| guard let title, !title.isEmpty else { | |
| panel.resetTitle() | |
| panelTitles[panel.id] = panel.displayTitle | |
| return | |
| } | |
| panel.updateTitle(title) | |
| panelTitles[panel.id] = panel.displayTitle |
| if tab.isSidebarTerminalPanel(surfaceId) { | ||
| return true |
There was a problem hiding this comment.
P2: matchesCurrentTerminalFocusTarget unconditionally returns true for any sidebar terminal panel. With both left and right sidebar terminals visible, deferred applyFirstResponderIfNeeded() calls will treat both as valid focus targets, potentially stealing keyboard focus from the sidebar terminal the user actually interacted with. Gate this on tab.focusedPanelId == surfaceId to match the existing focus-ownership pattern.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/GhosttyTerminalView.swift, line 10862:
<comment>`matchesCurrentTerminalFocusTarget` unconditionally returns `true` for any sidebar terminal panel. With both left and right sidebar terminals visible, deferred `applyFirstResponderIfNeeded()` calls will treat both as valid focus targets, potentially stealing keyboard focus from the sidebar terminal the user actually interacted with. Gate this on `tab.focusedPanelId == surfaceId` to match the existing focus-ownership pattern.</comment>
<file context>
@@ -10849,8 +10855,15 @@ final class GhosttySurfaceScrollView: NSView {
+ return false
+ }
+
+ if tab.isSidebarTerminalPanel(surfaceId) {
+ return true
+ }
</file context>
| if tab.isSidebarTerminalPanel(surfaceId) { | |
| return true | |
| if tab.isSidebarTerminalPanel(surfaceId) { | |
| return tab.focusedPanelId == surfaceId | |
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9588df6640
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| manualUnreadMarkedAt.removeValue(forKey: panelId) | ||
| panelSubscriptions.removeValue(forKey: panelId) | ||
| panelShellActivityStates.removeValue(forKey: panelId) | ||
| surfaceTTYNames.removeValue(forKey: panelId) |
There was a problem hiding this comment.
Sync remote TTY state after closing sidebar terminal
When a sidebar terminal is closed in a remote workspace, this path deletes surfaceTTYNames but never calls syncRemotePortScanTTYs(), unlike the Bonsplit close paths (didCloseTab/didClosePane). That leaves the remote scanner with stale TTY registrations until some unrelated event re-syncs, so remote port detection/conflict state can remain incorrect after the sidebar terminal is gone.
Useful? React with 👍 / 👎.
| panelSubscriptions.removeValue(forKey: panelId) | ||
| panelShellActivityStates.removeValue(forKey: panelId) | ||
| surfaceTTYNames.removeValue(forKey: panelId) | ||
| surfaceListeningPorts.removeValue(forKey: panelId) |
There was a problem hiding this comment.
Recompute aggregate listening ports after sidebar close
This close path removes the panel from surfaceListeningPorts but never calls recomputeListeningPorts(), so listeningPorts can retain stale ports from the closed sidebar terminal. PortScanner.unregisterPanel only drops internal tracking and does not emit an onPortsUpdated callback, so there is no guaranteed later recomputation to correct the published aggregate.
Useful? React with 👍 / 👎.
Adds selectable terminal modes to the left and right sidebars.
What changed:
Verification:
Summary by cubic
Add terminal modes to both sidebars for quick access to a per-side terminal. Supports per-side config in
cmux.json(local overrides global) and keeps sidebar terminals out of Bonsplit layout and workspace serialization.New Features
cmux.json: optionalsidebar.left/right.terminalwithtitle,command, andcwd; global and local configs are merged.cwd), and runcommandonce on start.Refactors
GhosttyTerminalView,TerminalController,Workspace).Written for commit 9588df6. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests